From fd15324ef068104396d175b09f7f890daaad9131 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 14 Jan 2026 13:54:01 +0900 Subject: [PATCH] feat(address): integrate AddressModule and optimize Japan address form handling --- apps/bff/src/core/config/router.config.ts | 2 + apps/bff/src/core/rate-limiting/index.ts | 1 + .../src/core/rate-limiting/rate-limit.util.ts | 14 + .../services/japanpost-address.service.ts | 37 +- .../services/japanpost-connection.service.ts | 178 ++++- .../services/whmcs-error-handler.service.ts | 53 +- apps/bff/src/modules/auth/auth.module.ts | 13 +- apps/bff/src/modules/auth/auth.types.ts | 6 + .../auth/decorators/public.decorator.ts | 15 + .../infra/otp/get-started-session.service.ts | 211 ++++++ apps/bff/src/modules/auth/infra/otp/index.ts | 2 + .../src/modules/auth/infra/otp/otp.service.ts | 208 ++++++ .../workflows/get-started-workflow.service.ts | 573 ++++++++++++++++ .../workflows/password-workflow.service.ts | 21 +- .../signup/signup-validation.service.ts | 6 +- .../infra/workflows/signup/signup.types.ts | 9 +- .../auth/presentation/http/auth.controller.ts | 14 +- .../http/get-started.controller.ts | 177 +++++ .../http/guards/global-auth.guard.ts | 33 +- .../bff/src/modules/users/users.controller.ts | 14 +- .../(public)/(site)/auth/get-started/page.tsx | 5 + .../app/(public)/(site)/auth/migrate/page.tsx | 4 +- .../app/(public)/(site)/auth/signup/page.tsx | 4 +- .../address/components/AddressStepJapan.tsx | 57 +- .../address/components/JapanAddressForm.tsx | 220 +++--- .../get-started/api/get-started.api.ts | 85 +++ .../GetStartedForm/GetStartedForm.tsx | 75 +++ .../components/GetStartedForm/index.ts | 1 + .../steps/AccountStatusStep.tsx | 145 ++++ .../steps/CompleteAccountStep.tsx | 456 +++++++++++++ .../GetStartedForm/steps/EmailStep.tsx | 94 +++ .../GetStartedForm/steps/SuccessStep.tsx | 43 ++ .../GetStartedForm/steps/VerificationStep.tsx | 111 +++ .../components/GetStartedForm/steps/index.ts | 5 + .../components/OtpInput/OtpInput.tsx | 167 +++++ .../get-started/components/OtpInput/index.ts | 1 + .../features/get-started/components/index.ts | 2 + apps/portal/src/features/get-started/index.ts | 25 + .../get-started/stores/get-started.store.ts | 274 ++++++++ .../get-started/views/GetStartedView.tsx | 80 +++ .../src/features/get-started/views/index.ts | 1 + .../internet/EligibilityStatusBadge.tsx | 70 ++ .../internet/InternetIneligibleState.tsx | 27 + .../internet/InternetPendingState.tsx | 47 ++ .../services/components/vpn/VpnPlanCard.tsx | 64 +- .../src/features/services/utils/index.ts | 2 + .../services/utils/internet-config.ts | 339 ++++++++++ .../services/utils/service-features.tsx | 197 ++++++ .../features/services/views/InternetPlans.tsx | 637 ++++++------------ .../services/views/PublicInternetPlans.tsx | 184 ++--- .../services/views/PublicVpnPlans.tsx | 61 +- docs/features/unified-get-started-flow.md | 181 +++++ docs/integrations/japanpost/api-reference.md | 231 +++++++ packages/domain/auth/contract.ts | 29 + packages/domain/auth/index.ts | 4 + packages/domain/auth/schema.ts | 15 + packages/domain/get-started/contract.ts | 112 +++ packages/domain/get-started/index.ts | 58 ++ packages/domain/get-started/schema.ts | 231 +++++++ packages/domain/package.json | 4 + .../services/providers/salesforce/mapper.ts | 4 - packages/domain/services/schema.ts | 10 +- packages/domain/tsconfig.json | 1 + 63 files changed, 5053 insertions(+), 897 deletions(-) create mode 100644 apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts create mode 100644 apps/bff/src/modules/auth/infra/otp/index.ts create mode 100644 apps/bff/src/modules/auth/infra/otp/otp.service.ts create mode 100644 apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts create mode 100644 apps/bff/src/modules/auth/presentation/http/get-started.controller.ts create mode 100644 apps/portal/src/app/(public)/(site)/auth/get-started/page.tsx create mode 100644 apps/portal/src/features/get-started/api/get-started.api.ts create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/index.ts create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/EmailStep.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/SuccessStep.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx create mode 100644 apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts create mode 100644 apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx create mode 100644 apps/portal/src/features/get-started/components/OtpInput/index.ts create mode 100644 apps/portal/src/features/get-started/components/index.ts create mode 100644 apps/portal/src/features/get-started/index.ts create mode 100644 apps/portal/src/features/get-started/stores/get-started.store.ts create mode 100644 apps/portal/src/features/get-started/views/GetStartedView.tsx create mode 100644 apps/portal/src/features/get-started/views/index.ts create mode 100644 apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx create mode 100644 apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx create mode 100644 apps/portal/src/features/services/components/internet/InternetPendingState.tsx create mode 100644 apps/portal/src/features/services/utils/internet-config.ts create mode 100644 apps/portal/src/features/services/utils/service-features.tsx create mode 100644 docs/features/unified-get-started-flow.md create mode 100644 docs/integrations/japanpost/api-reference.md create mode 100644 packages/domain/get-started/contract.ts create mode 100644 packages/domain/get-started/index.ts create mode 100644 packages/domain/get-started/schema.ts diff --git a/apps/bff/src/core/config/router.config.ts b/apps/bff/src/core/config/router.config.ts index a94a46b8..be59e0b7 100644 --- a/apps/bff/src/core/config/router.config.ts +++ b/apps/bff/src/core/config/router.config.ts @@ -16,6 +16,7 @@ import { SupportModule } from "@bff/modules/support/support.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; import { VerificationModule } from "@bff/modules/verification/verification.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; +import { AddressModule } from "@bff/modules/address/address.module.js"; export const apiRoutes: Routes = [ { @@ -38,6 +39,7 @@ export const apiRoutes: Routes = [ { path: "", module: RealtimeApiModule }, { path: "", module: VerificationModule }, { path: "", module: NotificationsModule }, + { path: "", module: AddressModule }, ], }, ]; diff --git a/apps/bff/src/core/rate-limiting/index.ts b/apps/bff/src/core/rate-limiting/index.ts index bbc1be5d..c38d3d2e 100644 --- a/apps/bff/src/core/rate-limiting/index.ts +++ b/apps/bff/src/core/rate-limiting/index.ts @@ -6,3 +6,4 @@ export { type RateLimitOptions, RATE_LIMIT_KEY, } from "./rate-limit.decorator.js"; +export { getRequestFingerprint } from "./rate-limit.util.js"; diff --git a/apps/bff/src/core/rate-limiting/rate-limit.util.ts b/apps/bff/src/core/rate-limiting/rate-limit.util.ts index d2c34613..7d36a2ec 100644 --- a/apps/bff/src/core/rate-limiting/rate-limit.util.ts +++ b/apps/bff/src/core/rate-limiting/rate-limit.util.ts @@ -18,6 +18,20 @@ export function getRequestRateLimitIdentity(request: Request): { }; } +/** + * Generate a fingerprint from request for session binding + * Combines IP and User-Agent hash to create a unique identifier + * Used for OTP verification to detect context changes + * + * @param request - Express request object + * @returns SHA256 hash of IP + User-Agent (truncated to 32 chars) + */ +export function getRequestFingerprint(request: Request): string { + const { ip, userAgentHash } = getRequestRateLimitIdentity(request); + const combined = `${ip}:${userAgentHash}`; + return createHash("sha256").update(combined).digest("hex").slice(0, 32); +} + export function buildRateLimitKey(...parts: Array): string { return parts.filter(Boolean).join(":"); } diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts index 352d4e09..819e7928 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-address.service.ts @@ -38,38 +38,61 @@ export class JapanPostAddressService { // Validate format if (!/^\d{7}$/.test(normalizedZip)) { + this.logger.warn("Invalid ZIP code format", { + zipCode, + normalizedZip, + reason: "Must be exactly 7 digits", + }); throw new BadRequestException("ZIP code must be 7 digits (e.g., 100-0001)"); } // Check if service is configured if (!this.connection.isConfigured()) { - this.logger.error("Japan Post API not configured"); + this.logger.error("Japan Post API not configured - address lookup unavailable", { + zipCode: normalizedZip, + configErrors: this.connection.getConfigErrors(), + }); throw new ServiceUnavailableException("Address lookup service is not available"); } + const startTime = Date.now(); + try { const rawResponse = await this.connection.searchByZipCode(normalizedZip); // Use domain mapper for transformation (single transformation point) const result = JapanPost.transformJapanPostSearchResponse(rawResponse); - this.logger.log("Japan Post address lookup completed", { + this.logger.log("Address lookup completed", { zipCode: normalizedZip, found: result.count > 0, count: result.count, + durationMs: Date.now() - startTime, }); return result; } catch (error) { - // Re-throw known exceptions + const durationMs = Date.now() - startTime; + + // Re-throw known exceptions (already logged at connection layer) if (error instanceof BadRequestException || error instanceof ServiceUnavailableException) { throw error; } - this.logger.error("Japan Post address lookup failed", { - zipCode: normalizedZip, - error: extractErrorMessage(error), - }); + // Check if this is an HTTP error from connection layer (already logged there) + const errorMessage = extractErrorMessage(error); + const isConnectionError = errorMessage.includes("HTTP") || errorMessage.includes("timed out"); + + if (!isConnectionError) { + // Only log unexpected errors (e.g., transformation failures) + this.logger.error("Address lookup failed at service layer", { + zipCode: normalizedZip, + durationMs, + errorType: error instanceof Error ? error.constructor.name : "Unknown", + error: errorMessage, + stage: "response_transformation", + }); + } throw new ServiceUnavailableException("Failed to lookup address. Please try again."); } diff --git a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts index 2c9469fe..c5b6f983 100644 --- a/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts +++ b/apps/bff/src/integrations/japanpost/services/japanpost-connection.service.ts @@ -33,6 +33,15 @@ interface ConfigValidationError { message: string; } +/** + * Japan Post API error response format + */ +interface JapanPostErrorResponse { + request_id?: string; + error_code?: string; + message?: string; +} + @Injectable() export class JapanPostConnectionService implements OnModuleInit { private accessToken: string | null = null; @@ -142,9 +151,11 @@ export class JapanPostConnectionService implements OnModuleInit { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + const startTime = Date.now(); + const tokenUrl = `${this.config.baseUrl}/api/v1/j/token`; try { - const response = await fetch(`${this.config.baseUrl}/api/v1/j/token`, { + const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -158,11 +169,24 @@ export class JapanPostConnectionService implements OnModuleInit { signal: controller.signal, }); + const durationMs = Date.now() - startTime; + if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new Error( - `Token request failed: HTTP ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}` - ); + const errorBody = await response.text().catch(() => ""); + const parsedError = this.parseErrorResponse(errorBody); + + this.logger.error("Japan Post token request failed", { + endpoint: tokenUrl, + httpStatus: response.status, + httpStatusText: response.statusText, + durationMs, + // Japan Post API error details + requestId: parsedError?.request_id, + errorCode: parsedError?.error_code, + apiMessage: parsedError?.message, + hint: this.getErrorHint(response.status, parsedError?.error_code), + }); + throw new Error(`Token request failed: HTTP ${response.status}`); } const data = (await response.json()) as JapanPostTokenResponse; @@ -175,19 +199,98 @@ export class JapanPostConnectionService implements OnModuleInit { this.logger.debug("Japan Post token acquired", { expiresIn: validated.expires_in, tokenType: validated.token_type, + durationMs, }); return token; } catch (error) { - this.logger.error("Failed to acquire Japan Post access token", { - error: extractErrorMessage(error), - }); + const durationMs = Date.now() - startTime; + const isAborted = error instanceof Error && error.name === "AbortError"; + + if (isAborted) { + this.logger.error("Japan Post token request timed out", { + endpoint: tokenUrl, + timeoutMs: this.config.timeout, + durationMs, + }); + throw new Error(`Token request timed out after ${this.config.timeout}ms`); + } + + // Only log if not already logged above (non-ok response) + if (!(error instanceof Error && error.message.startsWith("Token request failed"))) { + this.logger.error("Japan Post token request error", { + endpoint: tokenUrl, + error: extractErrorMessage(error), + durationMs, + }); + } throw error; } finally { clearTimeout(timeoutId); } } + /** + * Parse Japan Post API error response + */ + private parseErrorResponse(body: string): JapanPostErrorResponse | null { + if (!body) return null; + try { + const parsed = JSON.parse(body) as JapanPostErrorResponse; + // Validate it has the expected shape + if (parsed && typeof parsed === "object") { + return { + request_id: parsed.request_id, + error_code: parsed.error_code, + message: parsed.message, + }; + } + return null; + } catch { + return null; + } + } + + /** + * Get a helpful hint based on HTTP status code and API error code + */ + private getErrorHint(status: number, errorCode?: string): string { + // Handle specific Japan Post error codes (format: "400-1028-0001") + if (errorCode) { + if (errorCode.startsWith("400-1028")) { + return "Invalid client_id or secret_key - check JAPAN_POST_CLIENT_ID and JAPAN_POST_CLIENT_SECRET"; + } + if (errorCode.startsWith("401")) { + return "Token is invalid or expired - will retry with fresh token"; + } + if (errorCode.startsWith("404")) { + return "ZIP code not found in Japan Post database"; + } + } + + // Fall back to HTTP status hints + switch (status) { + case 400: + return "Invalid request - check ZIP code format (7 digits)"; + case 401: + return "Token expired or invalid - check credentials"; + case 403: + return "Access forbidden - API credentials may be suspended"; + case 404: + return "ZIP code not found in Japan Post database"; + case 429: + return "Rate limit exceeded - reduce request frequency"; + case 500: + return "Japan Post API internal error - retry later"; + case 502: + case 503: + case 504: + return "Japan Post API is temporarily unavailable - retry later"; + default: + return "Unexpected error - check API configuration"; + } + } + /** * Search addresses by ZIP code * @@ -199,12 +302,12 @@ export class JapanPostConnectionService implements OnModuleInit { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + const startTime = Date.now(); + const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`; + + this.logger.debug("Japan Post ZIP code search started", { zipCode }); try { - const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`; - - this.logger.debug("Japan Post ZIP code search", { zipCode, url }); - const response = await fetch(url, { method: "GET", headers: { @@ -214,26 +317,59 @@ export class JapanPostConnectionService implements OnModuleInit { signal: controller.signal, }); + const durationMs = Date.now() - startTime; + if (!response.ok) { - const errorText = await response.text().catch(() => ""); - throw new Error( - `ZIP code search failed: HTTP ${response.status}${errorText ? ` - ${errorText}` : ""}` - ); + const errorBody = await response.text().catch(() => ""); + const parsedError = this.parseErrorResponse(errorBody); + + this.logger.error("Japan Post ZIP search request failed", { + zipCode, + endpoint: url, + httpStatus: response.status, + httpStatusText: response.statusText, + durationMs, + // Japan Post API error details + requestId: parsedError?.request_id, + errorCode: parsedError?.error_code, + apiMessage: parsedError?.message, + hint: this.getErrorHint(response.status, parsedError?.error_code), + }); + throw new Error(`ZIP code search failed: HTTP ${response.status}`); } const data = await response.json(); - this.logger.debug("Japan Post search response received", { + this.logger.debug("Japan Post ZIP search completed", { zipCode, resultCount: (data as { count?: number }).count, + durationMs, }); return data; } catch (error) { - this.logger.error("Japan Post ZIP code search failed", { - zipCode, - error: extractErrorMessage(error), - }); + const durationMs = Date.now() - startTime; + const isAborted = error instanceof Error && error.name === "AbortError"; + + if (isAborted) { + this.logger.error("Japan Post ZIP search timed out", { + zipCode, + endpoint: url, + timeoutMs: this.config.timeout, + durationMs, + }); + throw new Error(`ZIP search timed out after ${this.config.timeout}ms`); + } + + // Only log if not already logged above (non-ok response) + if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) { + this.logger.error("Japan Post ZIP search error", { + zipCode, + endpoint: url, + error: extractErrorMessage(error), + durationMs, + }); + } throw error; } finally { clearTimeout(timeoutId); diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index 327220ea..462f255c 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -3,6 +3,7 @@ import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { matchCommonError } from "@bff/core/errors/index.js"; /** * Service for handling and normalizing WHMCS API errors @@ -27,8 +28,10 @@ export class WhmcsErrorHandlerService { /** * Handle general request errors (network, timeout, etc.) + * @param error - The error to handle + * @param _context - Context string for error messages (kept for signature consistency) */ - handleRequestError(error: unknown): never { + handleRequestError(error: unknown, _context?: string): never { if (this.isTimeoutError(error)) { throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } @@ -37,9 +40,25 @@ export class WhmcsErrorHandlerService { throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); } - // If upstream already threw a DomainHttpException or HttpException with code, - // let the global exception filter handle it. - throw error; + if (this.isRateLimitError(error)) { + throw new DomainHttpException( + ErrorCode.RATE_LIMITED, + HttpStatus.TOO_MANY_REQUESTS, + "WHMCS rate limit exceeded" + ); + } + + // Re-throw if already a DomainHttpException + if (error instanceof DomainHttpException) { + throw error; + } + + // Wrap unknown errors + throw new DomainHttpException( + ErrorCode.EXTERNAL_SERVICE_ERROR, + HttpStatus.BAD_GATEWAY, + _context ? `WHMCS ${_context} failed` : "WHMCS operation failed" + ); } private mapProviderErrorToDomain( @@ -164,20 +183,30 @@ export class WhmcsErrorHandlerService { ); } + /** + * Check if error is a rate limit error + */ + private isRateLimitError(error: unknown): boolean { + const message = extractErrorMessage(error).toLowerCase(); + return ( + message.includes("rate limit") || + message.includes("too many requests") || + message.includes("429") + ); + } + /** * Get user-friendly error message for client consumption */ getUserFriendlyMessage(error: unknown): string { - const message = extractErrorMessage(error).toLowerCase(); + const message = extractErrorMessage(error); - if (message.includes("timeout")) { - return "The request timed out. Please try again."; + // Use shared error pattern matcher with billing category + const commonResult = matchCommonError(message, "billing"); + if (commonResult.matched) { + return commonResult.message; } - if (message.includes("network") || message.includes("connection")) { - return "Network error. Please check your connection and try again."; - } - - return "An unexpected error occurred. Please try again later."; + return "Billing operation failed. Please try again or contact support."; } } diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 94027193..d6197929 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -11,6 +11,7 @@ import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js"; import { TokenStorageService } from "./infra/token/token-storage.service.js"; import { TokenRevocationService } from "./infra/token/token-revocation.service.js"; +import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js"; import { EmailModule } from "@bff/infra/email/email.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { AuthTokenService } from "./infra/token/token.service.js"; @@ -25,10 +26,15 @@ import { SignupAccountResolverService } from "./infra/workflows/signup/signup-ac import { SignupValidationService } from "./infra/workflows/signup/signup-validation.service.js"; import { SignupWhmcsService } from "./infra/workflows/signup/signup-whmcs.service.js"; import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-creation.service.js"; +// Get Started flow +import { OtpService } from "./infra/otp/otp.service.js"; +import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js"; +import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflow.service.js"; +import { GetStartedController } from "./presentation/http/get-started.controller.js"; @Module({ imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule], - controllers: [AuthController], + controllers: [AuthController, GetStartedController], providers: [ // Application services AuthFacade, @@ -40,6 +46,7 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user- TokenRevocationService, AuthTokenService, JoseJwtService, + PasswordResetTokenService, // Signup workflow services SignupWorkflowService, SignupAccountResolverService, @@ -49,6 +56,10 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user- // Other workflow services PasswordWorkflowService, WhmcsLinkWorkflowService, + // Get Started flow services + OtpService, + GetStartedSessionService, + GetStartedWorkflowService, // Guards and interceptors FailedLoginThrottleGuard, AuthRateLimitService, diff --git a/apps/bff/src/modules/auth/auth.types.ts b/apps/bff/src/modules/auth/auth.types.ts index 24375bfb..4b862ec4 100644 --- a/apps/bff/src/modules/auth/auth.types.ts +++ b/apps/bff/src/modules/auth/auth.types.ts @@ -5,6 +5,12 @@ import type { UserAuth } from "@customer-portal/domain/customer"; export type RequestWithUser = Request & { user: User }; +/** + * Request with optional user - used for @OptionalAuth() endpoints + * where no token = user is undefined, valid token = user is attached + */ +export type RequestWithOptionalUser = Request & { user?: User }; + /** * Internal auth result used inside the BFF to set cookies. * Token strings must not be returned to the browser. diff --git a/apps/bff/src/modules/auth/decorators/public.decorator.ts b/apps/bff/src/modules/auth/decorators/public.decorator.ts index 834188ee..a80b7032 100644 --- a/apps/bff/src/modules/auth/decorators/public.decorator.ts +++ b/apps/bff/src/modules/auth/decorators/public.decorator.ts @@ -11,3 +11,18 @@ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); */ export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession"; export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true); + +/** + * Marks a route as optionally authenticated. + * + * Behavior: + * - No token: allow request, user = null (200 response, controller handles null user) + * - Valid token: allow request, user attached (normal authenticated flow) + * - Invalid/expired token: return 401 (signals "session expired" to client) + * + * Use this for "check if I'm logged in" endpoints like /me where: + * - Unauthenticated users should get a graceful response (not 401) + * - Users with expired sessions SHOULD get 401 (to trigger re-login) + */ +export const IS_OPTIONAL_AUTH_KEY = "isOptionalAuth"; +export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH_KEY, true); diff --git a/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts new file mode 100644 index 00000000..b00fe870 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/otp/get-started-session.service.ts @@ -0,0 +1,211 @@ +import { randomUUID } from "crypto"; + +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +import type { GetStartedSession, AccountStatus } from "@customer-portal/domain/get-started"; + +import { CacheService } from "@/infra/cache/cache.service.js"; + +/** + * Session data stored in Redis (internal representation) + */ +interface SessionData extends Omit { + /** Session ID (for lookup) */ + id: string; +} + +/** + * Get Started Session Service + * + * Manages temporary sessions for the unified "Get Started" flow. + * Sessions track progress through email verification, account detection, + * and account completion. + * + * Security: + * - Sessions are stored in Redis with automatic TTL + * - Session tokens are UUIDs (unguessable) + * - Sessions are invalidated after account creation + */ +@Injectable() +export class GetStartedSessionService { + private readonly SESSION_PREFIX = "get-started-session:"; + private readonly ttlSeconds: number; + + constructor( + private readonly cache: CacheService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.ttlSeconds = this.config.get("GET_STARTED_SESSION_TTL", 3600); // 1 hour + } + + /** + * Create a new session for email verification + * + * @param email - Email address (normalized) + * @returns Session token (UUID) + */ + async create(email: string): Promise { + const sessionId = randomUUID(); + const normalizedEmail = email.toLowerCase().trim(); + + const sessionData: SessionData = { + id: sessionId, + email: normalizedEmail, + emailVerified: false, + createdAt: new Date().toISOString(), + }; + + await this.cache.set(this.buildKey(sessionId), sessionData, this.ttlSeconds); + + this.logger.debug({ email: normalizedEmail, sessionId }, "Get-started session created"); + + return sessionId; + } + + /** + * Get session by token + * + * @param sessionToken - Session token (UUID) + * @returns Session data or null if not found/expired + */ + async get(sessionToken: string): Promise { + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + + if (!sessionData) { + return null; + } + + return { + ...sessionData, + expiresAt: this.calculateExpiresAt(sessionData.createdAt), + }; + } + + /** + * Update session with email verification status + */ + async markEmailVerified( + sessionToken: string, + accountStatus: AccountStatus, + prefillData?: { + firstName?: string; + lastName?: string; + phone?: string; + address?: GetStartedSession["address"]; + sfAccountId?: string; + whmcsClientId?: number; + eligibilityStatus?: string; + } + ): Promise { + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + + if (!sessionData) { + return false; + } + + const updatedData: SessionData = { + ...sessionData, + emailVerified: true, + accountStatus, + ...prefillData, + }; + + // Calculate remaining TTL + const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt); + await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl); + + this.logger.debug( + { sessionId: sessionToken, accountStatus }, + "Get-started session marked as verified" + ); + + return true; + } + + /** + * Update session with quick check data + */ + async updateWithQuickCheckData( + sessionToken: string, + data: { + firstName: string; + lastName: string; + address: GetStartedSession["address"]; + phone?: string; + sfAccountId?: string; + } + ): Promise { + const sessionData = await this.cache.get(this.buildKey(sessionToken)); + + if (!sessionData) { + return false; + } + + const updatedData: SessionData = { + ...sessionData, + firstName: data.firstName, + lastName: data.lastName, + address: data.address, + phone: data.phone, + sfAccountId: data.sfAccountId, + }; + + const remainingTtl = this.calculateRemainingTtl(sessionData.createdAt); + await this.cache.set(this.buildKey(sessionToken), updatedData, remainingTtl); + + this.logger.debug( + { sessionId: sessionToken }, + "Get-started session updated with quick check data" + ); + + return true; + } + + /** + * Delete session (after successful account creation) + */ + async invalidate(sessionToken: string): Promise { + await this.cache.del(this.buildKey(sessionToken)); + this.logger.debug({ sessionId: sessionToken }, "Get-started session invalidated"); + } + + /** + * Validate that a session exists and email is verified + * + * @returns Session data if valid, null otherwise + */ + async validateVerifiedSession(sessionToken: string): Promise { + const session = await this.get(sessionToken); + + if (!session) { + this.logger.warn({ sessionId: sessionToken }, "Session not found"); + return null; + } + + if (!session.emailVerified) { + this.logger.warn({ sessionId: sessionToken }, "Session email not verified"); + return null; + } + + return session; + } + + private buildKey(sessionId: string): string { + return `${this.SESSION_PREFIX}${sessionId}`; + } + + private calculateExpiresAt(createdAt: string): string { + const created = new Date(createdAt); + const expires = new Date(created.getTime() + this.ttlSeconds * 1000); + return expires.toISOString(); + } + + private calculateRemainingTtl(createdAt: string): number { + const created = new Date(createdAt).getTime(); + const elapsed = (Date.now() - created) / 1000; + return Math.max(1, Math.floor(this.ttlSeconds - elapsed)); + } +} diff --git a/apps/bff/src/modules/auth/infra/otp/index.ts b/apps/bff/src/modules/auth/infra/otp/index.ts new file mode 100644 index 00000000..b9675083 --- /dev/null +++ b/apps/bff/src/modules/auth/infra/otp/index.ts @@ -0,0 +1,2 @@ +export { OtpService, type OtpVerifyResult } from "./otp.service.js"; +export { GetStartedSessionService } from "./get-started-session.service.js"; diff --git a/apps/bff/src/modules/auth/infra/otp/otp.service.ts b/apps/bff/src/modules/auth/infra/otp/otp.service.ts new file mode 100644 index 00000000..a80afb8c --- /dev/null +++ b/apps/bff/src/modules/auth/infra/otp/otp.service.ts @@ -0,0 +1,208 @@ +import { randomBytes } from "crypto"; + +import { Injectable, Inject } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; + +import { CacheService } from "@/infra/cache/cache.service.js"; + +/** + * OTP data stored in Redis + */ +interface OtpData { + /** The 6-digit code */ + code: string; + /** Number of verification attempts */ + attempts: number; + /** When the code was created */ + createdAt: string; + /** Optional fingerprint binding (SHA256 hash of IP + User-Agent) */ + fingerprint?: string; +} + +/** + * OTP verification result + */ +export interface OtpVerifyResult { + /** Whether the code was valid */ + valid: boolean; + /** Reason for failure (if invalid) */ + reason?: "expired" | "invalid" | "max_attempts"; + /** Remaining attempts (if invalid but not expired) */ + attemptsRemaining?: number; +} + +/** + * OTP Service + * + * Manages one-time password generation and verification for email verification. + * Uses Redis for storage with automatic TTL expiration. + * + * Security features: + * - Cryptographically secure code generation + * - Limited verification attempts (default: 3) + * - Automatic expiration (default: 10 minutes) + * - Code invalidation after successful verification + */ +@Injectable() +export class OtpService { + private readonly OTP_PREFIX = "otp:"; + private readonly ttlSeconds: number; + private readonly maxAttempts: number; + + constructor( + private readonly cache: CacheService, + private readonly config: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + this.ttlSeconds = this.config.get("OTP_TTL_SECONDS", 600); // 10 minutes + this.maxAttempts = this.config.get("OTP_MAX_ATTEMPTS", 3); + } + + /** + * Generate a new OTP code and store it for the given email + * If a code already exists for this email, it will be replaced + * + * @param email - Email address to generate code for (will be normalized) + * @param fingerprint - Optional fingerprint to bind OTP to (e.g., SHA256 of IP + User-Agent) + * @returns The generated 6-digit code + */ + async generateAndStore(email: string, fingerprint?: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const code = this.generateSecureCode(); + const key = this.buildKey(normalizedEmail); + + const otpData: OtpData = { + code, + attempts: 0, + createdAt: new Date().toISOString(), + fingerprint, + }; + + await this.cache.set(key, otpData, this.ttlSeconds); + + this.logger.log( + { email: normalizedEmail, hasFingerprint: !!fingerprint }, + "OTP code generated" + ); + + return code; + } + + /** + * Verify an OTP code for the given email + * + * @param email - Email address to verify code for + * @param code - The 6-digit code to verify + * @param fingerprint - Optional fingerprint to check against stored value (logs warning if mismatch) + * @returns Verification result with validity and reason if invalid + */ + async verify(email: string, code: string, fingerprint?: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const key = this.buildKey(normalizedEmail); + + const otpData = await this.cache.get(key); + + // Code not found or expired + if (!otpData) { + this.logger.warn( + { email: normalizedEmail }, + "OTP verification failed: code expired or not found" + ); + return { valid: false, reason: "expired" }; + } + + // Check fingerprint mismatch (soft check - warn only, don't block) + // Users may legitimately switch devices/networks between requesting and verifying + if (otpData.fingerprint && fingerprint && otpData.fingerprint !== fingerprint) { + this.logger.warn( + { email: normalizedEmail }, + "OTP verification from different context than request (fingerprint mismatch)" + ); + } + + // Max attempts reached + if (otpData.attempts >= this.maxAttempts) { + this.logger.warn({ email: normalizedEmail }, "OTP verification failed: max attempts reached"); + await this.cache.del(key); + return { valid: false, reason: "max_attempts" }; + } + + // Invalid code + if (otpData.code !== code) { + const newAttempts = otpData.attempts + 1; + const attemptsRemaining = this.maxAttempts - newAttempts; + + // Update attempts count (keeping existing TTL would be ideal, but we reset it for simplicity) + const updatedData: OtpData = { + ...otpData, + attempts: newAttempts, + }; + + // Calculate remaining TTL + const createdAt = new Date(otpData.createdAt).getTime(); + const elapsed = (Date.now() - createdAt) / 1000; + const remainingTtl = Math.max(1, Math.floor(this.ttlSeconds - elapsed)); + + await this.cache.set(key, updatedData, remainingTtl); + + this.logger.warn( + { email: normalizedEmail, attemptsRemaining }, + "OTP verification failed: invalid code" + ); + + return { valid: false, reason: "invalid", attemptsRemaining }; + } + + // Valid code - delete it (one-time use) + await this.cache.del(key); + + this.logger.log({ email: normalizedEmail }, "OTP verification successful"); + + return { valid: true }; + } + + /** + * Check if an OTP exists for the given email (without consuming attempts) + */ + async exists(email: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const key = this.buildKey(normalizedEmail); + const otpData = await this.cache.get(key); + return otpData !== null; + } + + /** + * Delete any existing OTP for the given email + */ + async invalidate(email: string): Promise { + const normalizedEmail = this.normalizeEmail(email); + const key = this.buildKey(normalizedEmail); + await this.cache.del(key); + } + + /** + * Generate a cryptographically secure 6-digit code + */ + private generateSecureCode(): string { + // Use 4 random bytes to generate a number, then mod by 1000000 + const buffer = randomBytes(4); + const num = buffer.readUInt32BE(0) % 1000000; + // Pad with leading zeros to ensure 6 digits + return num.toString().padStart(6, "0"); + } + + /** + * Build Redis key for OTP storage + */ + private buildKey(email: string): string { + return `${this.OTP_PREFIX}${email}`; + } + + /** + * Normalize email for consistent key generation + */ + private normalizeEmail(email: string): string { + return email.toLowerCase().trim(); + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts new file mode 100644 index 00000000..4bdbc4fe --- /dev/null +++ b/apps/bff/src/modules/auth/infra/workflows/get-started-workflow.service.ts @@ -0,0 +1,573 @@ +import { BadRequestException, ConflictException, Inject, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; +import * as argon2 from "argon2"; + +import { + ACCOUNT_STATUS, + type AccountStatus, + type SendVerificationCodeRequest, + type SendVerificationCodeResponse, + type VerifyCodeRequest, + type VerifyCodeResponse, + type QuickEligibilityRequest, + type QuickEligibilityResponse, + type CompleteAccountRequest, + type MaybeLaterRequest, + type MaybeLaterResponse, +} from "@customer-portal/domain/get-started"; + +import { EmailService } from "@bff/infra/email/email.service.js"; +import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; +import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; +import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; +import { OpportunityResolutionService } from "@bff/integrations/salesforce/services/opportunity-resolution.service.js"; +import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; +import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; +import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; +import type { AuthResultInternal } from "@bff/modules/auth/auth.types.js"; + +import { OtpService } from "../otp/otp.service.js"; +import { GetStartedSessionService } from "../otp/get-started-session.service.js"; +import { AuthTokenService } from "../token/token.service.js"; +import { SignupWhmcsService } from "./signup/signup-whmcs.service.js"; +import { SignupUserCreationService } from "./signup/signup-user-creation.service.js"; +import { + PORTAL_SOURCE_NEW_SIGNUP, + PORTAL_STATUS_ACTIVE, +} from "@bff/modules/auth/constants/portal.constants.js"; +import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; + +/** + * Get Started Workflow Service + * + * Orchestrates the unified "Get Started" flow: + * 1. Email verification via OTP + * 2. Account status detection (Portal, WHMCS, SF) + * 3. Quick eligibility check for guests + * 4. Account completion for SF-only users + */ +@Injectable() +export class GetStartedWorkflowService { + constructor( + private readonly config: ConfigService, + private readonly otpService: OtpService, + private readonly sessionService: GetStartedSessionService, + private readonly emailService: EmailService, + private readonly usersFacade: UsersFacade, + private readonly mappingsService: MappingsService, + private readonly auditService: AuditService, + private readonly salesforceAccountService: SalesforceAccountService, + private readonly salesforceService: SalesforceService, + private readonly opportunityResolution: OpportunityResolutionService, + private readonly caseService: SalesforceCaseService, + private readonly whmcsDiscovery: WhmcsAccountDiscoveryService, + private readonly whmcsSignup: SignupWhmcsService, + private readonly userCreation: SignupUserCreationService, + private readonly tokenService: AuthTokenService, + @Inject(Logger) private readonly logger: Logger + ) {} + + // ============================================================================ + // Email Verification + // ============================================================================ + + /** + * Send OTP verification code to email + * + * @param request - The request containing the email + * @param fingerprint - Optional request fingerprint for session binding + */ + async sendVerificationCode( + request: SendVerificationCodeRequest, + fingerprint?: string + ): Promise { + const { email } = request; + const normalizedEmail = email.toLowerCase().trim(); + + try { + // Generate OTP and store in Redis (with fingerprint for binding) + const code = await this.otpService.generateAndStore(normalizedEmail, fingerprint); + + // Create session for this verification flow (token returned in verifyCode) + await this.sessionService.create(normalizedEmail); + + // Send email with OTP code + await this.sendOtpEmail(normalizedEmail, code); + + this.logger.log({ email: normalizedEmail }, "OTP verification code sent"); + + return { + sent: true, + message: "Verification code sent to your email", + }; + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: normalizedEmail }, + "Failed to send verification code" + ); + + return { + sent: false, + message: "Failed to send verification code. Please try again.", + }; + } + } + + /** + * Verify OTP code and determine account status + * + * @param request - The request containing email and code + * @param fingerprint - Optional request fingerprint for session binding check + */ + async verifyCode(request: VerifyCodeRequest, fingerprint?: string): Promise { + const { email, code } = request; + const normalizedEmail = email.toLowerCase().trim(); + + // Verify OTP (with fingerprint check - logs warning if different context) + const otpResult = await this.otpService.verify(normalizedEmail, code, fingerprint); + + if (!otpResult.valid) { + return { + verified: false, + error: + otpResult.reason === "expired" + ? "Code expired. Please request a new one." + : otpResult.reason === "max_attempts" + ? "Too many failed attempts. Please request a new code." + : "Invalid code. Please try again.", + attemptsRemaining: otpResult.attemptsRemaining, + }; + } + + // Create verified session + const sessionToken = await this.sessionService.create(normalizedEmail); + + // Check account status across all systems + const accountStatus = await this.determineAccountStatus(normalizedEmail); + + // Get prefill data if account exists + const prefill = this.getPrefillData(normalizedEmail, accountStatus); + + // Update session with verified status and account info + await this.sessionService.markEmailVerified(sessionToken, accountStatus.status, { + firstName: prefill?.firstName, + lastName: prefill?.lastName, + phone: prefill?.phone, + address: prefill?.address, + sfAccountId: accountStatus.sfAccountId, + whmcsClientId: accountStatus.whmcsClientId, + eligibilityStatus: prefill?.eligibilityStatus, + }); + + this.logger.log( + { email: normalizedEmail, accountStatus: accountStatus.status }, + "Email verified and account status determined" + ); + + return { + verified: true, + sessionToken, + accountStatus: accountStatus.status, + prefill, + }; + } + + // ============================================================================ + // Quick Eligibility Check (Guest Flow) + // ============================================================================ + + /** + * Quick eligibility check for guests + * Creates SF Account + eligibility case + */ + async quickEligibilityCheck(request: QuickEligibilityRequest): Promise { + const session = await this.sessionService.validateVerifiedSession(request.sessionToken); + if (!session) { + throw new BadRequestException("Invalid or expired session. Please verify your email again."); + } + + const { firstName, lastName, address, phone } = request; + + try { + // Check if SF account already exists for this email + let sfAccountId: string; + + const existingSf = await this.salesforceAccountService.findByEmail(session.email); + + if (existingSf) { + sfAccountId = existingSf.id; + this.logger.log({ email: session.email }, "Using existing SF account for quick check"); + } else { + // Create new SF Account + const { accountId } = await this.salesforceAccountService.createAccount({ + firstName, + lastName, + email: session.email, + phone: phone ?? "", + }); + sfAccountId = accountId; + this.logger.log( + { email: session.email, sfAccountId }, + "Created SF account for quick check" + ); + } + + // Create eligibility case + const requestId = await this.createEligibilityCase(sfAccountId, address); + + // Update session with SF account info + await this.sessionService.updateWithQuickCheckData(request.sessionToken, { + firstName, + lastName, + address, + phone, + sfAccountId, + }); + + return { + submitted: true, + requestId, + sfAccountId, + message: "Eligibility check submitted. We'll notify you of the results.", + }; + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: session.email }, + "Quick eligibility check failed" + ); + + return { + submitted: false, + message: "Failed to submit eligibility check. Please try again.", + }; + } + } + + /** + * "Maybe Later" flow - create SF Account + case, customer returns later + */ + async maybeLater(request: MaybeLaterRequest): Promise { + // This is essentially the same as quickEligibilityCheck + // but with explicit intent to not create account now + const result = await this.quickEligibilityCheck(request); + + if (result.submitted) { + // Send confirmation email + await this.sendMaybeLaterConfirmationEmail( + (await this.sessionService.get(request.sessionToken))!.email, + request.firstName, + result.requestId! + ); + } + + return { + success: result.submitted, + requestId: result.requestId, + message: result.submitted + ? "Your eligibility check has been submitted. Check your email for updates." + : result.message, + }; + } + + // ============================================================================ + // Account Completion (SF-Only Users) + // ============================================================================ + + /** + * Complete account for users with SF account but no WHMCS/Portal + * Creates WHMCS client and Portal user, links to existing SF account + */ + async completeAccount(request: CompleteAccountRequest): Promise { + const session = await this.sessionService.validateVerifiedSession(request.sessionToken); + if (!session) { + throw new BadRequestException("Invalid or expired session. Please verify your email again."); + } + + if (!session.sfAccountId) { + throw new BadRequestException("No Salesforce account found. Please check eligibility first."); + } + + const { password, phone, dateOfBirth, gender } = request; + + // Verify SF account still exists + const existingSf = await this.salesforceAccountService.findByEmail(session.email); + if (!existingSf || existingSf.id !== session.sfAccountId) { + throw new BadRequestException("Account verification failed. Please start over."); + } + + // Check for existing WHMCS client (shouldn't exist for SF-only flow) + const existingWhmcs = await this.whmcsDiscovery.findClientByEmail(session.email); + if (existingWhmcs) { + throw new ConflictException( + "A billing account already exists. Please use the account migration flow." + ); + } + + // Check for existing portal user + const existingPortalUser = await this.usersFacade.findByEmailInternal(session.email); + if (existingPortalUser) { + throw new ConflictException("An account already exists. Please log in."); + } + + const passwordHash = await argon2.hash(password); + + try { + // Get address from session or SF + const address = session.address; + if (!address || !address.address1 || !address.city || !address.postcode) { + throw new BadRequestException("Address information is incomplete."); + } + + // Create WHMCS client + const whmcsClient = await this.whmcsSignup.createClient({ + firstName: session.firstName!, + lastName: session.lastName!, + email: session.email, + password, + phone, + address: { + address1: address.address1, + address2: address.address2 ?? undefined, + city: address.city, + state: address.state ?? "", + postcode: address.postcode, + country: address.country ?? "Japan", + }, + customerNumber: existingSf.accountNumber, + dateOfBirth, + gender, + }); + + // Create portal user and mapping + const { userId } = await this.userCreation.createUserWithMapping({ + email: session.email, + passwordHash, + whmcsClientId: whmcsClient.clientId, + sfAccountId: session.sfAccountId, + }); + + // Fetch fresh user and generate tokens + const freshUser = await this.usersFacade.findByIdInternal(userId); + if (!freshUser) { + throw new Error("Failed to load created user"); + } + + await this.auditService.logAuthEvent(AuditAction.SIGNUP, userId, { + email: session.email, + whmcsClientId: whmcsClient.clientId, + source: "get_started_complete_account", + }); + + const profile = mapPrismaUserToDomain(freshUser); + const tokens = await this.tokenService.generateTokenPair({ + id: profile.id, + email: profile.email, + }); + + // Update Salesforce portal flags + await this.updateSalesforcePortalFlags(session.sfAccountId, whmcsClient.clientId); + + // Invalidate session + await this.sessionService.invalidate(request.sessionToken); + + this.logger.log( + { email: session.email, userId }, + "Account completed successfully for SF-only user" + ); + + return { + user: profile, + tokens, + }; + } catch (error) { + this.logger.error( + { error: extractErrorMessage(error), email: session.email }, + "Account completion failed" + ); + throw error; + } + } + + // ============================================================================ + // Private Helpers + // ============================================================================ + + private async sendOtpEmail(email: string, code: string): Promise { + const templateId = this.config.get("EMAIL_TEMPLATE_OTP_VERIFICATION"); + + if (templateId) { + await this.emailService.sendEmail({ + to: email, + subject: "Your verification code", + templateId, + dynamicTemplateData: { + code, + expiresMinutes: "10", + }, + }); + } else { + // Fallback to plain HTML + await this.emailService.sendEmail({ + to: email, + subject: "Your verification code", + html: ` +

Your verification code is: ${code}

+

This code expires in 10 minutes.

+

If you didn't request this code, please ignore this email.

+ `, + }); + } + } + + private async sendMaybeLaterConfirmationEmail( + email: string, + firstName: string, + requestId: string + ): Promise { + const appBase = this.config.get("APP_BASE_URL", "http://localhost:3000"); + const templateId = this.config.get("EMAIL_TEMPLATE_ELIGIBILITY_SUBMITTED"); + + if (templateId) { + await this.emailService.sendEmail({ + to: email, + subject: "We're checking internet availability at your address", + templateId, + dynamicTemplateData: { + firstName, + portalUrl: appBase, + email, + requestId, + }, + }); + } else { + await this.emailService.sendEmail({ + to: email, + subject: "We're checking internet availability at your address", + html: ` +

Hi ${firstName},

+

We received your request to check internet availability.

+

We'll review this and email you the results within 1-2 business days.

+

When ready, create your account at: ${appBase}/get-started

+

Just enter your email (${email}) to continue.

+ `, + }); + } + } + + private async determineAccountStatus( + email: string + ): Promise<{ status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }> { + // Check Portal user first + const portalUser = await this.usersFacade.findByEmailInternal(email); + if (portalUser) { + const hasMapping = await this.mappingsService.hasMapping(portalUser.id); + if (hasMapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + } + + // Check WHMCS client + const whmcsClient = await this.whmcsDiscovery.findClientByEmail(email); + if (whmcsClient) { + // Check if WHMCS is already mapped + const mapping = await this.mappingsService.findByWhmcsClientId(whmcsClient.id); + if (mapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + return { status: ACCOUNT_STATUS.WHMCS_UNMAPPED, whmcsClientId: whmcsClient.id }; + } + + // Check Salesforce account + const sfAccount = await this.salesforceAccountService.findByEmail(email); + if (sfAccount) { + // Check if SF is already mapped + const mapping = await this.mappingsService.findBySfAccountId(sfAccount.id); + if (mapping) { + return { status: ACCOUNT_STATUS.PORTAL_EXISTS }; + } + return { status: ACCOUNT_STATUS.SF_UNMAPPED, sfAccountId: sfAccount.id }; + } + + // No account exists + return { status: ACCOUNT_STATUS.NEW_CUSTOMER }; + } + + private getPrefillData( + email: string, + accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number } + ): VerifyCodeResponse["prefill"] { + // For SF-only accounts, we could query SF for the stored address/name + // For now, return minimal data - frontend will handle input + if (accountStatus.status === ACCOUNT_STATUS.SF_UNMAPPED && accountStatus.sfAccountId) { + // TODO: Query SF for stored account details + return { + email, + }; + } + + return undefined; + } + + private async createEligibilityCase( + sfAccountId: string, + address: QuickEligibilityRequest["address"] + ): Promise { + // Find or create Opportunity for Internet eligibility + const { opportunityId } = + await this.opportunityResolution.findOrCreateForInternetEligibility(sfAccountId); + + // Build case description + const addressString = [ + address.address1, + address.address2, + address.city, + address.state, + address.postcode, + address.country, + ] + .filter(Boolean) + .join(", "); + + const { id: caseId } = await this.caseService.createCase({ + accountId: sfAccountId, + opportunityId, + subject: "Internet availability check request (Portal - Quick Check)", + description: `Customer requested to check if internet service is available at the following address:\n\n${addressString}`, + origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, + }); + + // Update Account eligibility status to Pending + this.updateAccountEligibilityStatus(sfAccountId); + + return caseId; + } + + private updateAccountEligibilityStatus(sfAccountId: string): void { + // TODO: Implement SF Account eligibility status update + // This would update the SF Account eligibility fields using salesforceService + this.logger.debug({ sfAccountId }, "Updating account eligibility status to Pending"); + } + + private async updateSalesforcePortalFlags( + accountId: string, + whmcsClientId: number + ): Promise { + try { + await this.salesforceService.updateAccountPortalFields(accountId, { + status: PORTAL_STATUS_ACTIVE, + source: PORTAL_SOURCE_NEW_SIGNUP, + lastSignedInAt: new Date(), + whmcsAccountId: whmcsClientId, + }); + } catch (error) { + this.logger.warn( + { error: extractErrorMessage(error), accountId }, + "Failed to update Salesforce portal flags" + ); + } + } +} diff --git a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index 92214416..2ed97437 100644 --- a/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -15,7 +15,7 @@ import { EmailService } from "@bff/infra/email/email.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { AuthTokenService } from "../token/token.service.js"; import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js"; -import { JoseJwtService } from "../token/jose-jwt.service.js"; +import { PasswordResetTokenService } from "../token/password-reset-token.service.js"; import { type ChangePasswordRequest, changePasswordRequestSchema, @@ -31,7 +31,7 @@ export class PasswordWorkflowService { private readonly auditService: AuditService, private readonly configService: ConfigService, private readonly emailService: EmailService, - private readonly jwtService: JoseJwtService, + private readonly passwordResetTokenService: PasswordResetTokenService, private readonly tokenService: AuthTokenService, private readonly authRateLimitService: AuthRateLimitService, @Inject(Logger) private readonly logger: Logger @@ -113,10 +113,12 @@ export class PasswordWorkflowService { } const user = await this.usersFacade.findByEmailInternal(email); if (!user) { + // Don't reveal whether user exists return; } - const token = await this.jwtService.sign({ sub: user.id, purpose: "password_reset" }, "15m"); + // Create single-use password reset token + const token = await this.passwordResetTokenService.create(user.id); const appBase = this.configService.get("APP_BASE_URL", "http://localhost:3000"); const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; @@ -143,14 +145,13 @@ export class PasswordWorkflowService { } async resetPassword(token: string, newPassword: string): Promise { + // Consume the token (single-use enforcement) + // This will throw BadRequestException if token is invalid, expired, or already used + const { userId } = await this.passwordResetTokenService.consume(token); + return withErrorHandling( async () => { - const payload = await this.jwtService.verify<{ sub: string; purpose: string }>(token); - if (payload.purpose !== "password_reset") { - throw new BadRequestException("Invalid token"); - } - - const prismaUser = await this.usersFacade.findByIdInternal(payload.sub); + const prismaUser = await this.usersFacade.findByIdInternal(userId); if (!prismaUser) throw new BadRequestException("Invalid token"); const passwordHash = await argon2.hash(newPassword); @@ -166,7 +167,7 @@ export class PasswordWorkflowService { this.logger, { context: "Reset password", - fallbackMessage: "Invalid or expired token", + fallbackMessage: "Failed to reset password", rethrow: [BadRequestException], } ); diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts index 639b3174..d4420605 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup/signup-validation.service.ts @@ -223,9 +223,11 @@ export class SignupValidationService { try { const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); if (existingSf) { - result.nextAction = "blocked"; + // SF account exists without WHMCS - allow account completion via get-started flow + result.salesforce.accountId = existingSf.id; + result.nextAction = "complete_account"; result.messages.push( - "We found an existing customer record for this email. Please transfer your account or contact support." + "We found your existing account. Please verify your email to complete setup." ); return result; } diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts index e903f021..2696e64a 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup/signup.types.ts @@ -24,7 +24,14 @@ export interface SignupAccountCacheEntry { */ export interface SignupPreflightResult { canProceed: boolean; - nextAction: "login" | "proceed_signup" | "link_whmcs" | "fix_input" | "blocked" | null; + nextAction: + | "login" + | "proceed_signup" + | "link_whmcs" + | "fix_input" + | "blocked" + | "complete_account" + | null; messages: string[]; normalized: { email: string; diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 1410ef9c..1b263b17 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -18,7 +18,7 @@ import { type RequestWithRateLimit, } from "./guards/failed-login-throttle.guard.js"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js"; -import { Public } from "../../decorators/public.decorator.js"; +import { Public, OptionalAuth } from "../../decorators/public.decorator.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; @@ -301,8 +301,18 @@ export class AuthController { return { user: result.user, session: this.toSession(result.tokens) }; } + /** + * GET /auth/me - Check authentication status + * + * Uses @OptionalAuth: returns isAuthenticated: false if not logged in, + * 401 only if session cookie is present but expired/invalid + */ + @OptionalAuth() @Get("me") - getAuthStatus(@Req() req: Request & { user: UserAuth }) { + getAuthStatus(@Req() req: Request & { user?: UserAuth }) { + if (!req.user) { + return { isAuthenticated: false }; + } return { isAuthenticated: true, user: req.user }; } diff --git a/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts new file mode 100644 index 00000000..c990fc7b --- /dev/null +++ b/apps/bff/src/modules/auth/presentation/http/get-started.controller.ts @@ -0,0 +1,177 @@ +import { Controller, Post, Body, UseGuards, Res, Req, HttpCode } from "@nestjs/common"; +import type { Request, Response } from "express"; +import { createZodDto } from "nestjs-zod"; + +import { RateLimitGuard, RateLimit, getRequestFingerprint } from "@bff/core/rate-limiting/index.js"; +import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js"; +import { Public } from "../../decorators/public.decorator.js"; + +import { + sendVerificationCodeRequestSchema, + sendVerificationCodeResponseSchema, + verifyCodeRequestSchema, + verifyCodeResponseSchema, + quickEligibilityRequestSchema, + quickEligibilityResponseSchema, + completeAccountRequestSchema, + maybeLaterRequestSchema, + maybeLaterResponseSchema, +} from "@customer-portal/domain/get-started"; + +import { GetStartedWorkflowService } from "../../infra/workflows/get-started-workflow.service.js"; + +// DTO classes using Zod schemas +class SendVerificationCodeRequestDto extends createZodDto(sendVerificationCodeRequestSchema) {} +class SendVerificationCodeResponseDto extends createZodDto(sendVerificationCodeResponseSchema) {} +class VerifyCodeRequestDto extends createZodDto(verifyCodeRequestSchema) {} +class VerifyCodeResponseDto extends createZodDto(verifyCodeResponseSchema) {} +class QuickEligibilityRequestDto extends createZodDto(quickEligibilityRequestSchema) {} +class QuickEligibilityResponseDto extends createZodDto(quickEligibilityResponseSchema) {} +class CompleteAccountRequestDto extends createZodDto(completeAccountRequestSchema) {} +class MaybeLaterRequestDto extends createZodDto(maybeLaterRequestSchema) {} +class MaybeLaterResponseDto extends createZodDto(maybeLaterResponseSchema) {} + +const ACCESS_COOKIE_PATH = "/api"; +const REFRESH_COOKIE_PATH = "/api/auth/refresh"; +const TOKEN_TYPE = "Bearer" as const; + +const calculateCookieMaxAge = (isoTimestamp: string): number => { + const expiresAt = Date.parse(isoTimestamp); + if (Number.isNaN(expiresAt)) { + return 0; + } + return Math.max(0, expiresAt - Date.now()); +}; + +/** + * Get Started Controller + * + * Handles the unified "Get Started" flow: + * - Email verification via OTP + * - Account status detection + * - Quick eligibility check (guest) + * - Account completion (SF-only users) + * + * All endpoints are public (no authentication required) + */ +@Controller("auth/get-started") +export class GetStartedController { + constructor(private readonly workflow: GetStartedWorkflowService) {} + + /** + * Send OTP verification code to email + * + * Rate limit: 5 codes per 5 minutes per IP + */ + @Public() + @Post("send-code") + @HttpCode(200) + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 5, ttl: 300 }) + async sendVerificationCode( + @Body() body: SendVerificationCodeRequestDto, + @Req() req: Request + ): Promise { + const fingerprint = getRequestFingerprint(req); + return this.workflow.sendVerificationCode(body, fingerprint); + } + + /** + * Verify OTP code and determine account status + * + * Rate limit: 10 attempts per 5 minutes per IP + */ + @Public() + @Post("verify-code") + @HttpCode(200) + @UseGuards(RateLimitGuard) + @RateLimit({ limit: 10, ttl: 300 }) + async verifyCode( + @Body() body: VerifyCodeRequestDto, + @Req() req: Request + ): Promise { + const fingerprint = getRequestFingerprint(req); + return this.workflow.verifyCode(body, fingerprint); + } + + /** + * Quick eligibility check for guests + * Creates SF Account + eligibility case + * + * Rate limit: 5 per 15 minutes per IP + */ + @Public() + @Post("quick-check") + @HttpCode(200) + @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) + @RateLimit({ limit: 5, ttl: 900 }) + async quickEligibilityCheck( + @Body() body: QuickEligibilityRequestDto + ): Promise { + return this.workflow.quickEligibilityCheck(body); + } + + /** + * "Maybe Later" flow + * Creates SF Account + eligibility case, sends confirmation email + * + * Rate limit: 3 per 10 minutes per IP + */ + @Public() + @Post("maybe-later") + @HttpCode(200) + @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) + @RateLimit({ limit: 3, ttl: 600 }) + async maybeLater(@Body() body: MaybeLaterRequestDto): Promise { + return this.workflow.maybeLater(body); + } + + /** + * Complete account for SF-only users + * Creates WHMCS client and Portal user, links to existing SF account + * + * Returns auth tokens (sets httpOnly cookies) + * + * Rate limit: 5 per 15 minutes per IP + */ + @Public() + @Post("complete-account") + @HttpCode(200) + @UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard) + @RateLimit({ limit: 5, ttl: 900 }) + async completeAccount( + @Body() body: CompleteAccountRequestDto, + @Res({ passthrough: true }) res: Response + ) { + const result = await this.workflow.completeAccount(body); + + // Set auth cookies (same pattern as signup) + const accessExpires = result.tokens.expiresAt; + const refreshExpires = result.tokens.refreshExpiresAt; + + res.cookie("access_token", result.tokens.accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: ACCESS_COOKIE_PATH, + maxAge: calculateCookieMaxAge(accessExpires), + }); + + res.cookie("refresh_token", result.tokens.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: REFRESH_COOKIE_PATH, + maxAge: calculateCookieMaxAge(refreshExpires), + }); + + return { + user: result.user, + session: { + expiresAt: accessExpires, + refreshExpiresAt: refreshExpires, + tokenType: TOKEN_TYPE, + }, + }; + } +} diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index 16c11a5b..a3bd196d 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -5,7 +5,11 @@ import { Reflector } from "@nestjs/core"; import type { Request } from "express"; import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; -import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js"; +import { + IS_PUBLIC_KEY, + IS_PUBLIC_NO_SESSION_KEY, + IS_OPTIONAL_AUTH_KEY, +} from "../../../decorators/public.decorator.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; @@ -70,6 +74,33 @@ export class GlobalAuthGuard implements CanActivate { return true; } + // Check if the route is marked as optionally authenticated + // OptionalAuth: no token = allow (user=null), invalid token = 401 + const isOptionalAuth = this.reflector.getAllAndOverride(IS_OPTIONAL_AUTH_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isOptionalAuth) { + const token = extractAccessTokenFromRequest(request); + if (!token) { + // No token = not logged in, allow request with no user attached + this.logger.debug(`Optional auth route accessed without token: ${route}`); + return true; + } + + // Token present - validate it strictly (invalid token = 401) + try { + await this.attachUserFromToken(request, token, route); + this.logger.debug(`Optional auth route accessed with valid token: ${route}`); + return true; + } catch (error) { + // Token is invalid/expired - return 401 to signal "session expired" + this.logger.debug(`Optional auth route - invalid token, returning 401: ${route}`); + throw error; + } + } + try { const token = extractAccessTokenFromRequest(request); if (!token) { diff --git a/apps/bff/src/modules/users/users.controller.ts b/apps/bff/src/modules/users/users.controller.ts index bb426102..7a809714 100644 --- a/apps/bff/src/modules/users/users.controller.ts +++ b/apps/bff/src/modules/users/users.controller.ts @@ -16,8 +16,9 @@ import { addressSchema, userSchema } from "@customer-portal/domain/customer"; import { bilingualAddressSchema } from "@customer-portal/domain/address"; import type { Address } from "@customer-portal/domain/customer"; import type { User } from "@customer-portal/domain/customer"; -import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; +import type { RequestWithUser, RequestWithOptionalUser } from "@bff/modules/auth/auth.types.js"; import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js"; +import { OptionalAuth } from "@bff/modules/auth/decorators/public.decorator.js"; class UpdateAddressDto extends createZodDto(addressSchema.partial()) {} class UpdateBilingualAddressDto extends createZodDto(bilingualAddressSchema) {} @@ -34,12 +35,17 @@ export class UsersController { /** * GET /me - Get complete customer profile (includes address) * Profile data fetched from WHMCS (single source of truth) + * + * Uses @OptionalAuth: returns null if not logged in, 401 only if session expired */ + @OptionalAuth() @UseGuards(SalesforceReadThrottleGuard) @Get() - @ZodResponse({ description: "Get user profile", type: UserDto }) - async getProfile(@Req() req: RequestWithUser): Promise { - // This endpoint represents the authenticated user; treat missing user as an error. + @ZodSerializerDto(userSchema.nullable()) + async getProfile(@Req() req: RequestWithOptionalUser): Promise { + if (!req.user) { + return null; + } return this.usersFacade.getProfile(req.user.id); } diff --git a/apps/portal/src/app/(public)/(site)/auth/get-started/page.tsx b/apps/portal/src/app/(public)/(site)/auth/get-started/page.tsx new file mode 100644 index 00000000..697ab097 --- /dev/null +++ b/apps/portal/src/app/(public)/(site)/auth/get-started/page.tsx @@ -0,0 +1,5 @@ +import { GetStartedView } from "@/features/get-started"; + +export default function GetStartedPage() { + return ; +} diff --git a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx index 33cf7d4c..67803013 100644 --- a/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx +++ b/apps/portal/src/app/(public)/(site)/auth/migrate/page.tsx @@ -1,5 +1,5 @@ -import MigrateAccountView from "@/features/auth/views/MigrateAccountView"; +import { redirect } from "next/navigation"; export default function MigrateAccountPage() { - return ; + redirect("/auth/get-started"); } diff --git a/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx b/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx index 73e4e940..c41a43de 100644 --- a/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx +++ b/apps/portal/src/app/(public)/(site)/auth/signup/page.tsx @@ -1,5 +1,5 @@ -import SignupView from "@/features/auth/views/SignupView"; +import { redirect } from "next/navigation"; export default function SignupPage() { - return ; + redirect("/auth/get-started"); } diff --git a/apps/portal/src/features/address/components/AddressStepJapan.tsx b/apps/portal/src/features/address/components/AddressStepJapan.tsx index 5e21f324..6a34d340 100644 --- a/apps/portal/src/features/address/components/AddressStepJapan.tsx +++ b/apps/portal/src/features/address/components/AddressStepJapan.tsx @@ -17,7 +17,7 @@ * Japanese fields are stored separately for Salesforce sync. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { JapanAddressForm, type JapanAddressFormData } from "./JapanAddressForm"; import { type BilingualAddress, @@ -106,8 +106,14 @@ function fromLegacyFormat(address: LegacyAddressData): Partial(() => ({ - postcode: address.postcode || "", - prefecture: address.state || "", - city: address.city || "", - town: address.address2 || "", - buildingName: "", - roomNumber: "", - residenceType: RESIDENCE_TYPE.APARTMENT, - prefectureJa: "", - cityJa: "", - townJa: "", - ...fromLegacyFormat(address), - })); + // Track if this is the first render to avoid infinite loops + const isInitialMount = useRef(true); + + // Compute initial values only once on mount + const initialValues = useMemo(() => { + return { + postcode: address.postcode || "", + prefecture: address.state || "", + city: address.city || "", + town: address.address2 || "", + buildingName: "", + roomNumber: "", + residenceType: undefined, + prefectureJa: "", + cityJa: "", + townJa: "", + ...fromLegacyFormat(address), + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only compute on mount // Extract address field errors const getError = (field: string): string | undefined => { @@ -176,7 +188,11 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ // Handle Japan address form changes const handleJapanAddressChange = useCallback( (data: JapanAddressFormData, _isComplete: boolean) => { - setJapanData(data); + // Skip the initial mount to avoid loops + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } // Convert to legacy format and update parent form const legacyAddress = toWhmcsFormat(data); @@ -214,11 +230,12 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ if (!address.country) { setValue("address", { ...address, country: "JP", countryCode: "JP" }); } - }, [address, setValue]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only on mount return ( (""); + // Store onChange in ref to avoid it triggering useEffect re-runs + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + // Update address when initialValues change useEffect(() => { if (initialValues) { @@ -114,157 +118,117 @@ export function JapanAddressForm({ return touched[field] ? errors[field] : undefined; }; - // Notify parent of address changes with completeness check - const notifyChange = useCallback( - (next: InternalFormState, verified: boolean) => { - const hasResidenceType = - next.residenceType === RESIDENCE_TYPE.HOUSE || - next.residenceType === RESIDENCE_TYPE.APARTMENT; + // Notify parent of address changes via useEffect (avoids setState during render) + useEffect(() => { + const hasResidenceType = + address.residenceType === RESIDENCE_TYPE.HOUSE || + address.residenceType === RESIDENCE_TYPE.APARTMENT; - const baseFieldsFilled = - next.postcode.trim() !== "" && - next.prefecture.trim() !== "" && - next.city.trim() !== "" && - next.town.trim() !== ""; + const baseFieldsFilled = + address.postcode.trim() !== "" && + address.prefecture.trim() !== "" && + address.city.trim() !== "" && + address.town.trim() !== ""; - // Room number is required for apartments - const roomNumberOk = - next.residenceType !== RESIDENCE_TYPE.APARTMENT || (next.roomNumber?.trim() ?? "") !== ""; + // Room number is required for apartments + const roomNumberOk = + address.residenceType !== RESIDENCE_TYPE.APARTMENT || + (address.roomNumber?.trim() ?? "") !== ""; - // Must have verified address from ZIP lookup - const isComplete = verified && hasResidenceType && baseFieldsFilled && roomNumberOk; + // Must have verified address from ZIP lookup + const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk; - onChange?.(next as JapanAddressFormData, isComplete); - }, - [onChange] - ); + // Use ref to avoid infinite loops when onChange changes reference + onChangeRef.current?.(address as JapanAddressFormData, isComplete); + }, [address, isAddressVerified]); // Handle ZIP code change - reset verification when ZIP changes const handleZipChange = useCallback( (value: string) => { const normalizedNew = value.replace(/-/g, ""); const normalizedVerified = verifiedZipCode.replace(/-/g, ""); + const shouldReset = normalizedNew !== normalizedVerified; - setAddress(prev => { - // If ZIP code changed from verified one, reset address fields - if (normalizedNew !== normalizedVerified) { - const next: InternalFormState = { - ...prev, - postcode: value, - prefecture: "", - prefectureJa: "", - city: "", - cityJa: "", - town: "", - townJa: "", - // Keep user-entered fields - buildingName: prev.buildingName, - roomNumber: prev.roomNumber, - residenceType: prev.residenceType, - }; - setIsAddressVerified(false); - notifyChange(next, false); - return next; - } - + if (shouldReset) { + // Reset address fields when ZIP changes + setIsAddressVerified(false); + setAddress(prev => ({ + ...prev, + postcode: value, + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + // Keep user-entered fields + buildingName: prev.buildingName, + roomNumber: prev.roomNumber, + residenceType: prev.residenceType, + })); + } else { // Just update postcode formatting - const next = { ...prev, postcode: value }; - notifyChange(next, isAddressVerified); - return next; - }); + setAddress(prev => ({ ...prev, postcode: value })); + } }, - [verifiedZipCode, isAddressVerified, notifyChange] + [verifiedZipCode] ); // Handle address found from ZIP lookup - const handleAddressFound = useCallback( - (found: JapanPostAddress) => { - setAddress(prev => { - const next: InternalFormState = { - ...prev, - // English (romanized) fields - for WHMCS - prefecture: found.prefectureRoma, - city: found.cityRoma, - town: found.townRoma, - // Japanese fields - for Salesforce - prefectureJa: found.prefecture, - cityJa: found.city, - townJa: found.town, - }; - - setIsAddressVerified(true); - setVerifiedZipCode(prev.postcode); - notifyChange(next, true); - return next; - }); - }, - [notifyChange] - ); + const handleAddressFound = useCallback((found: JapanPostAddress) => { + setAddress(prev => { + setIsAddressVerified(true); + setVerifiedZipCode(prev.postcode); + return { + ...prev, + // English (romanized) fields - for WHMCS + prefecture: found.prefectureRoma, + city: found.cityRoma, + town: found.townRoma, + // Japanese fields - for Salesforce + prefectureJa: found.prefecture, + cityJa: found.city, + townJa: found.town, + }; + }); + }, []); // Handle lookup completion (success or failure) - const handleLookupComplete = useCallback( - (found: boolean) => { - if (!found) { - // Clear address fields on failed lookup - setAddress(prev => { - const next: InternalFormState = { - ...prev, - prefecture: "", - prefectureJa: "", - city: "", - cityJa: "", - town: "", - townJa: "", - }; - setIsAddressVerified(false); - notifyChange(next, false); - return next; - }); - } - }, - [notifyChange] - ); + const handleLookupComplete = useCallback((found: boolean) => { + if (!found) { + // Clear address fields on failed lookup + setIsAddressVerified(false); + setAddress(prev => ({ + ...prev, + prefecture: "", + prefectureJa: "", + city: "", + cityJa: "", + town: "", + townJa: "", + })); + } + }, []); // Handle residence type change - const handleResidenceTypeChange = useCallback( - (type: ResidenceType) => { - setAddress(prev => { - const next: InternalFormState = { - ...prev, - residenceType: type, - // Clear room number when switching to house - roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber, - }; - notifyChange(next, isAddressVerified); - return next; - }); - }, - [isAddressVerified, notifyChange] - ); + const handleResidenceTypeChange = useCallback((type: ResidenceType) => { + setAddress(prev => ({ + ...prev, + residenceType: type, + // Clear room number when switching to house + roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber, + })); + }, []); // Handle building name change - const handleBuildingNameChange = useCallback( - (value: string) => { - setAddress(prev => { - const next = { ...prev, buildingName: value }; - notifyChange(next, isAddressVerified); - return next; - }); - }, - [isAddressVerified, notifyChange] - ); + const handleBuildingNameChange = useCallback((value: string) => { + setAddress(prev => ({ ...prev, buildingName: value })); + }, []); // Handle room number change - const handleRoomNumberChange = useCallback( - (value: string) => { - setAddress(prev => { - const next = { ...prev, roomNumber: value }; - notifyChange(next, isAddressVerified); - return next; - }); - }, - [isAddressVerified, notifyChange] - ); + const handleRoomNumberChange = useCallback((value: string) => { + setAddress(prev => ({ ...prev, roomNumber: value })); + }, []); const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT; const hasResidenceTypeSelected = diff --git a/apps/portal/src/features/get-started/api/get-started.api.ts b/apps/portal/src/features/get-started/api/get-started.api.ts new file mode 100644 index 00000000..a18236ac --- /dev/null +++ b/apps/portal/src/features/get-started/api/get-started.api.ts @@ -0,0 +1,85 @@ +/** + * Get Started API Client + * + * API calls for the unified get-started flow + */ + +import { apiClient, getDataOrThrow } from "@/core/api"; +import { + sendVerificationCodeResponseSchema, + verifyCodeResponseSchema, + quickEligibilityResponseSchema, + maybeLaterResponseSchema, + type SendVerificationCodeRequest, + type SendVerificationCodeResponse, + type VerifyCodeRequest, + type VerifyCodeResponse, + type QuickEligibilityRequest, + type QuickEligibilityResponse, + type CompleteAccountRequest, + type MaybeLaterRequest, + type MaybeLaterResponse, +} from "@customer-portal/domain/get-started"; +import { authResponseSchema, type AuthResponse } from "@customer-portal/domain/auth"; + +const BASE_PATH = "/api/auth/get-started"; + +/** + * Send OTP verification code to email + */ +export async function sendVerificationCode( + request: SendVerificationCodeRequest +): Promise { + const response = await apiClient.POST(`${BASE_PATH}/send-code`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to send verification code"); + return sendVerificationCodeResponseSchema.parse(data); +} + +/** + * Verify OTP code and get account status + */ +export async function verifyCode(request: VerifyCodeRequest): Promise { + const response = await apiClient.POST(`${BASE_PATH}/verify-code`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to verify code"); + return verifyCodeResponseSchema.parse(data); +} + +/** + * Quick eligibility check (guest flow) + */ +export async function quickEligibilityCheck( + request: QuickEligibilityRequest +): Promise { + const response = await apiClient.POST(`${BASE_PATH}/quick-check`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to submit eligibility check"); + return quickEligibilityResponseSchema.parse(data); +} + +/** + * Maybe later flow - create SF account and eligibility case + */ +export async function maybeLater(request: MaybeLaterRequest): Promise { + const response = await apiClient.POST(`${BASE_PATH}/maybe-later`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to submit request"); + return maybeLaterResponseSchema.parse(data); +} + +/** + * Complete account for SF-only users + * Returns auth response with user and session + */ +export async function completeAccount(request: CompleteAccountRequest): Promise { + const response = await apiClient.POST(`${BASE_PATH}/complete-account`, { + body: request, + }); + const data = getDataOrThrow(response, "Failed to complete account"); + return authResponseSchema.parse(data); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx new file mode 100644 index 00000000..73dff369 --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/GetStartedForm.tsx @@ -0,0 +1,75 @@ +/** + * GetStartedForm - Main form component for the unified get-started flow + * + * Flow: Email → OTP Verification → Account Status → Complete Account → Success + */ + +"use client"; + +import { useEffect } from "react"; +import { useGetStartedStore, type GetStartedStep } from "../../stores/get-started.store"; +import { + EmailStep, + VerificationStep, + AccountStatusStep, + CompleteAccountStep, + SuccessStep, +} from "./steps"; + +const stepComponents: Record = { + email: EmailStep, + verification: VerificationStep, + "account-status": AccountStatusStep, + "complete-account": CompleteAccountStep, + success: SuccessStep, +}; + +const stepTitles: Record = { + email: { + title: "Get Started", + subtitle: "Enter your email to begin", + }, + verification: { + title: "Verify Your Email", + subtitle: "Enter the code we sent to your email", + }, + "account-status": { + title: "Welcome", + subtitle: "Let's get you set up", + }, + "complete-account": { + title: "Create Your Account", + subtitle: "Just a few more details", + }, + success: { + title: "Account Created!", + subtitle: "You're all set", + }, +}; + +interface GetStartedFormProps { + /** Callback when step changes (for parent to update title) */ + onStepChange?: (step: GetStartedStep, meta: { title: string; subtitle: string }) => void; +} + +export function GetStartedForm({ onStepChange }: GetStartedFormProps) { + const { step, reset } = useGetStartedStore(); + + // Reset form on mount to ensure clean state + useEffect(() => { + reset(); + }, [reset]); + + // Notify parent of step changes + useEffect(() => { + onStepChange?.(step, stepTitles[step]); + }, [step, onStepChange]); + + const StepComponent = stepComponents[step]; + + return ( +
+ +
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/index.ts b/apps/portal/src/features/get-started/components/GetStartedForm/index.ts new file mode 100644 index 00000000..ada0c85b --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/index.ts @@ -0,0 +1 @@ +export { GetStartedForm } from "./GetStartedForm"; diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx new file mode 100644 index 00000000..75f28907 --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/AccountStatusStep.tsx @@ -0,0 +1,145 @@ +/** + * AccountStatusStep - Shows account status and routes to appropriate next step + * + * Routes based on account status: + * - portal_exists: Show login link + * - whmcs_unmapped: Link to migrate page (enter WHMCS password) + * - sf_unmapped: Go to complete-account step (pre-filled form) + * - new_customer: Go to complete-account step (full signup) + */ + +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/atoms"; +import { + CheckCircleIcon, + UserCircleIcon, + ArrowRightIcon, + DocumentCheckIcon, + UserPlusIcon, +} from "@heroicons/react/24/outline"; +import { useGetStartedStore } from "../../../stores/get-started.store"; + +export function AccountStatusStep() { + const { accountStatus, formData, goToStep, prefill } = useGetStartedStore(); + + // Portal exists - redirect to login + if (accountStatus === "portal_exists") { + return ( +
+
+
+ +
+
+ +
+

Account Found

+

+ You already have a portal account with this email. Please log in to continue. +

+
+ + + + +
+ ); + } + + // WHMCS exists but not mapped - need to link account + if (accountStatus === "whmcs_unmapped") { + return ( +
+
+
+ +
+
+ +
+

Existing Account Found

+

+ We found an existing billing account with this email. Please verify your password to + link it to your new portal account. +

+
+ + + + +
+ ); + } + + // SF exists but not mapped - complete account with pre-filled data + if (accountStatus === "sf_unmapped") { + return ( +
+
+
+ +
+
+ +
+

+ {prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"} +

+

+ We found your information from a previous inquiry. Just set a password to complete your + account setup. +

+ {prefill?.eligibilityStatus && ( +

+ Eligibility Status: {prefill.eligibilityStatus} +

+ )} +
+ + +
+ ); + } + + // New customer - proceed to full signup + return ( +
+
+
+ +
+
+ +
+

Email Verified!

+

+ Great! Let's set up your account so you can access all our services. +

+
+ + + +

+ Want to check service availability first?{" "} + + Check eligibility + {" "} + without creating an account. +

+
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx new file mode 100644 index 00000000..8b30f70e --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/CompleteAccountStep.tsx @@ -0,0 +1,456 @@ +/** + * CompleteAccountStep - Full account creation form + * + * Handles two cases: + * - SF-only users (prefill exists): Show pre-filled info, just add password + * - New customers (no prefill): Full form with name, address, password + */ + +"use client"; + +import { useState, useCallback } from "react"; +import { Button, Input, Label } from "@/components/atoms"; +import { Checkbox } from "@/components/atoms/checkbox"; +import { + JapanAddressForm, + type JapanAddressFormData, +} from "@/features/address/components/JapanAddressForm"; +import { prepareWhmcsAddressFields } from "@customer-portal/domain/address"; +import { useGetStartedStore } from "../../../stores/get-started.store"; +import { useRouter } from "next/navigation"; + +interface FormErrors { + firstName?: string; + lastName?: string; + address?: string; + password?: string; + confirmPassword?: string; + phone?: string; + dateOfBirth?: string; + gender?: string; + acceptTerms?: string; +} + +export function CompleteAccountStep() { + const router = useRouter(); + const { + formData, + updateFormData, + completeAccount, + prefill, + accountStatus, + loading, + error, + clearError, + goBack, + } = useGetStartedStore(); + + // Check if this is a new customer (needs full form) or SF-only (has prefill) + const isNewCustomer = accountStatus === "new_customer"; + const hasPrefill = !!(prefill?.firstName || prefill?.lastName); + + const [firstName, setFirstName] = useState(formData.firstName || prefill?.firstName || ""); + const [lastName, setLastName] = useState(formData.lastName || prefill?.lastName || ""); + const [isAddressComplete, setIsAddressComplete] = useState(!isNewCustomer); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [phone, setPhone] = useState(formData.phone || prefill?.phone || ""); + const [dateOfBirth, setDateOfBirth] = useState(formData.dateOfBirth); + const [gender, setGender] = useState<"male" | "female" | "other" | "">(formData.gender); + const [acceptTerms, setAcceptTerms] = useState(formData.acceptTerms); + const [marketingConsent, setMarketingConsent] = useState(formData.marketingConsent); + const [localErrors, setLocalErrors] = useState({}); + + // Handle address form changes (only for new customers) + const handleAddressChange = useCallback( + (data: JapanAddressFormData, isComplete: boolean) => { + setIsAddressComplete(isComplete); + const whmcsFields = prepareWhmcsAddressFields(data); + updateFormData({ + address: { + address1: whmcsFields.address1 || "", + address2: whmcsFields.address2 || "", + city: whmcsFields.city || "", + state: whmcsFields.state || "", + postcode: whmcsFields.postcode || "", + country: "JP", + }, + }); + }, + [updateFormData] + ); + + const validatePassword = (pass: string): string | undefined => { + if (!pass) return "Password is required"; + if (pass.length < 8) return "Password must be at least 8 characters"; + if (!/[A-Z]/.test(pass)) return "Password must contain an uppercase letter"; + if (!/[a-z]/.test(pass)) return "Password must contain a lowercase letter"; + if (!/[0-9]/.test(pass)) return "Password must contain a number"; + return undefined; + }; + + const validate = (): boolean => { + const errors: FormErrors = {}; + + // Validate name and address only for new customers + if (isNewCustomer) { + if (!firstName.trim()) { + errors.firstName = "First name is required"; + } + if (!lastName.trim()) { + errors.lastName = "Last name is required"; + } + if (!isAddressComplete) { + errors.address = "Please complete the address"; + } + } + + const passwordError = validatePassword(password); + if (passwordError) { + errors.password = passwordError; + } + + if (password !== confirmPassword) { + errors.confirmPassword = "Passwords do not match"; + } + + if (!phone.trim()) { + errors.phone = "Phone number is required"; + } + + if (!dateOfBirth) { + errors.dateOfBirth = "Date of birth is required"; + } + + if (!gender) { + errors.gender = "Please select a gender"; + } + + if (!acceptTerms) { + errors.acceptTerms = "You must accept the terms of service"; + } + + setLocalErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async () => { + clearError(); + + if (!validate()) { + return; + } + + // Update form data + updateFormData({ + firstName: firstName.trim(), + lastName: lastName.trim(), + password, + confirmPassword, + phone: phone.trim(), + dateOfBirth, + gender: gender as "male" | "female" | "other", + acceptTerms, + marketingConsent, + }); + + const result = await completeAccount(); + if (result) { + // Redirect to dashboard on success + router.push("/account/dashboard"); + } + }; + + const canSubmit = + password && + confirmPassword && + phone && + dateOfBirth && + gender && + acceptTerms && + (isNewCustomer ? firstName && lastName && isAddressComplete : true); + + return ( +
+ {/* Header */} +
+

+ {hasPrefill + ? `Welcome back, ${prefill?.firstName}! Just a few more details.` + : "Fill in your details to create your account."} +

+
+ + {/* Pre-filled info display (SF-only users) */} + {hasPrefill && ( +
+

Account for:

+

+ {prefill?.firstName} {prefill?.lastName} +

+ {prefill?.address && ( +

+ {prefill.address.city}, {prefill.address.state} +

+ )} +
+ )} + + {/* Name fields (new customers only) */} + {isNewCustomer && ( + <> +
+
+ + { + setFirstName(e.target.value); + setLocalErrors(prev => ({ ...prev, firstName: undefined })); + }} + placeholder="Taro" + disabled={loading} + error={localErrors.firstName} + /> + {localErrors.firstName && ( +

{localErrors.firstName}

+ )} +
+ +
+ + { + setLastName(e.target.value); + setLocalErrors(prev => ({ ...prev, lastName: undefined })); + }} + placeholder="Yamada" + disabled={loading} + error={localErrors.lastName} + /> + {localErrors.lastName && ( +

{localErrors.lastName}

+ )} +
+
+ + {/* Address Form (new customers only) */} +
+ + + {localErrors.address &&

{localErrors.address}

} +
+ + )} + + {/* Password */} +
+ + { + setPassword(e.target.value); + setLocalErrors(prev => ({ ...prev, password: undefined })); + }} + placeholder="Create a strong password" + disabled={loading} + error={localErrors.password} + autoComplete="new-password" + /> + {localErrors.password &&

{localErrors.password}

} +

+ At least 8 characters with uppercase, lowercase, and numbers +

+
+ + {/* Confirm Password */} +
+ + { + setConfirmPassword(e.target.value); + setLocalErrors(prev => ({ ...prev, confirmPassword: undefined })); + }} + placeholder="Confirm your password" + disabled={loading} + error={localErrors.confirmPassword} + autoComplete="new-password" + /> + {localErrors.confirmPassword && ( +

{localErrors.confirmPassword}

+ )} +
+ + {/* Phone */} +
+ + { + setPhone(e.target.value); + setLocalErrors(prev => ({ ...prev, phone: undefined })); + }} + placeholder="090-1234-5678" + disabled={loading} + error={localErrors.phone} + /> + {localErrors.phone &&

{localErrors.phone}

} +
+ + {/* Date of Birth */} +
+ + { + setDateOfBirth(e.target.value); + setLocalErrors(prev => ({ ...prev, dateOfBirth: undefined })); + }} + disabled={loading} + error={localErrors.dateOfBirth} + max={new Date().toISOString().split("T")[0]} + /> + {localErrors.dateOfBirth && ( +

{localErrors.dateOfBirth}

+ )} +
+ + {/* Gender */} +
+ +
+ {(["male", "female", "other"] as const).map(option => ( + + ))} +
+ {localErrors.gender &&

{localErrors.gender}

} +
+ + {/* Terms & Marketing */} +
+
+ { + setAcceptTerms(e.target.checked); + setLocalErrors(prev => ({ ...prev, acceptTerms: undefined })); + }} + disabled={loading} + /> + +
+ {localErrors.acceptTerms && ( +

{localErrors.acceptTerms}

+ )} + +
+ setMarketingConsent(e.target.checked)} + disabled={loading} + /> + +
+
+ + {/* Error display */} + {error && ( +
+

{error}

+
+ )} + + {/* Actions */} +
+ + + +
+
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/EmailStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/EmailStep.tsx new file mode 100644 index 00000000..bbb2255a --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/EmailStep.tsx @@ -0,0 +1,94 @@ +/** + * EmailStep - First step to enter email address + */ + +"use client"; + +import { useState } from "react"; +import { Button, Input, Label } from "@/components/atoms"; +import { useGetStartedStore } from "../../../stores/get-started.store"; + +export function EmailStep() { + const { formData, sendVerificationCode, loading, error, clearError } = useGetStartedStore(); + const [email, setEmail] = useState(formData.email); + const [localError, setLocalError] = useState(null); + + const validateEmail = (value: string): boolean => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); + }; + + const handleSubmit = async () => { + clearError(); + setLocalError(null); + + const trimmedEmail = email.trim().toLowerCase(); + + if (!trimmedEmail) { + setLocalError("Email address is required"); + return; + } + + if (!validateEmail(trimmedEmail)) { + setLocalError("Please enter a valid email address"); + return; + } + + await sendVerificationCode(trimmedEmail); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + }; + + const displayError = localError || error; + + return ( +
+
+ + { + setEmail(e.target.value); + setLocalError(null); + clearError(); + }} + onKeyDown={handleKeyDown} + disabled={loading} + error={displayError} + autoComplete="email" + autoFocus + /> + {displayError && ( +

+ {displayError} +

+ )} +
+ +
+

+ We'll send a verification code to confirm your email address. This helps us keep your + account secure. +

+
+ + +
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/SuccessStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/SuccessStep.tsx new file mode 100644 index 00000000..7d67c44f --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/SuccessStep.tsx @@ -0,0 +1,43 @@ +/** + * SuccessStep - Account creation success screen + */ + +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/atoms"; +import { CheckCircleIcon, ArrowRightIcon } from "@heroicons/react/24/outline"; + +export function SuccessStep() { + return ( +
+
+
+ +
+
+ +
+

Account Created!

+

+ Your account has been set up successfully. You can now access all our services. +

+
+ +
+ + + + + + + +
+
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx new file mode 100644 index 00000000..27b5af0f --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/VerificationStep.tsx @@ -0,0 +1,111 @@ +/** + * VerificationStep - Enter 6-digit OTP code + */ + +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/atoms"; +import { OtpInput } from "../../OtpInput"; +import { useGetStartedStore } from "../../../stores/get-started.store"; + +export function VerificationStep() { + const { + formData, + verifyCode, + sendVerificationCode, + loading, + error, + clearError, + attemptsRemaining, + goBack, + } = useGetStartedStore(); + + const [code, setCode] = useState(""); + const [resending, setResending] = useState(false); + + const handleCodeChange = (value: string) => { + setCode(value); + clearError(); + }; + + const handleCodeComplete = async (value: string) => { + await verifyCode(value); + }; + + const handleVerify = async () => { + if (code.length === 6) { + await verifyCode(code); + } + }; + + const handleResend = async () => { + setResending(true); + setCode(""); + clearError(); + await sendVerificationCode(formData.email); + setResending(false); + }; + + return ( +
+
+

Enter the 6-digit code sent to

+

{formData.email}

+
+ + + + {attemptsRemaining !== null && attemptsRemaining < 3 && ( +

+ {attemptsRemaining} {attemptsRemaining === 1 ? "attempt" : "attempts"} remaining +

+ )} + +
+ + +
+ + + +
+
+ +

+ The code expires in 10 minutes. Check your spam folder if you don't see it. +

+
+ ); +} diff --git a/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts b/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts new file mode 100644 index 00000000..d0235f12 --- /dev/null +++ b/apps/portal/src/features/get-started/components/GetStartedForm/steps/index.ts @@ -0,0 +1,5 @@ +export { EmailStep } from "./EmailStep"; +export { VerificationStep } from "./VerificationStep"; +export { AccountStatusStep } from "./AccountStatusStep"; +export { CompleteAccountStep } from "./CompleteAccountStep"; +export { SuccessStep } from "./SuccessStep"; diff --git a/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx new file mode 100644 index 00000000..bb8bc55b --- /dev/null +++ b/apps/portal/src/features/get-started/components/OtpInput/OtpInput.tsx @@ -0,0 +1,167 @@ +/** + * OtpInput - 6-digit OTP code input + * + * Auto-focuses next input on entry, handles paste, backspace navigation. + */ + +"use client"; + +import { + useRef, + useState, + useCallback, + useEffect, + type KeyboardEvent, + type ClipboardEvent, +} from "react"; +import { cn } from "@/shared/utils"; + +interface OtpInputProps { + length?: number; + value: string; + onChange: (value: string) => void; + onComplete?: (value: string) => void; + disabled?: boolean; + error?: string; + autoFocus?: boolean; +} + +export function OtpInput({ + length = 6, + value, + onChange, + onComplete, + disabled = false, + error, + autoFocus = true, +}: OtpInputProps) { + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const [activeIndex, setActiveIndex] = useState(0); + + // Split value into array of characters + const digits = value.split("").slice(0, length); + while (digits.length < length) { + digits.push(""); + } + + // Focus first empty input on mount + useEffect(() => { + if (autoFocus && !disabled) { + const firstEmptyIndex = digits.findIndex(d => !d); + const targetIndex = firstEmptyIndex === -1 ? 0 : firstEmptyIndex; + inputRefs.current[targetIndex]?.focus(); + } + }, [autoFocus, disabled, digits]); + + const focusInput = useCallback( + (index: number) => { + const clampedIndex = Math.max(0, Math.min(index, length - 1)); + inputRefs.current[clampedIndex]?.focus(); + setActiveIndex(clampedIndex); + }, + [length] + ); + + const handleChange = useCallback( + (index: number, char: string) => { + if (!/^\d?$/.test(char)) return; + + const newDigits = [...digits]; + newDigits[index] = char; + const newValue = newDigits.join(""); + onChange(newValue); + + // Move to next input if character entered + if (char && index < length - 1) { + focusInput(index + 1); + } + + // Check if complete + if (newValue.length === length && !newValue.includes("")) { + onComplete?.(newValue); + } + }, + [digits, length, onChange, onComplete, focusInput] + ); + + const handleKeyDown = useCallback( + (index: number, e: KeyboardEvent) => { + if (e.key === "Backspace") { + e.preventDefault(); + if (digits[index]) { + handleChange(index, ""); + } else if (index > 0) { + focusInput(index - 1); + handleChange(index - 1, ""); + } + } else if (e.key === "ArrowLeft" && index > 0) { + e.preventDefault(); + focusInput(index - 1); + } else if (e.key === "ArrowRight" && index < length - 1) { + e.preventDefault(); + focusInput(index + 1); + } + }, + [digits, focusInput, handleChange, length] + ); + + const handlePaste = useCallback( + (e: ClipboardEvent) => { + e.preventDefault(); + const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length); + if (pastedData) { + onChange(pastedData); + const nextIndex = Math.min(pastedData.length, length - 1); + focusInput(nextIndex); + + if (pastedData.length === length) { + onComplete?.(pastedData); + } + } + }, + [length, onChange, onComplete, focusInput] + ); + + return ( +
+
+ {digits.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + inputMode="numeric" + autoComplete="one-time-code" + maxLength={1} + value={digit} + disabled={disabled} + onChange={e => handleChange(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + onPaste={handlePaste} + onFocus={() => setActiveIndex(index)} + className={cn( + "w-12 h-14 text-center text-xl font-semibold", + "rounded-lg border bg-card text-foreground", + "focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary", + "transition-all duration-150", + "disabled:opacity-50 disabled:cursor-not-allowed", + error + ? "border-danger focus:ring-danger focus:border-danger" + : activeIndex === index + ? "border-primary" + : "border-border hover:border-muted-foreground/50" + )} + aria-label={`Digit ${index + 1}`} + /> + ))} +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/portal/src/features/get-started/components/OtpInput/index.ts b/apps/portal/src/features/get-started/components/OtpInput/index.ts new file mode 100644 index 00000000..053decd4 --- /dev/null +++ b/apps/portal/src/features/get-started/components/OtpInput/index.ts @@ -0,0 +1 @@ +export { OtpInput } from "./OtpInput"; diff --git a/apps/portal/src/features/get-started/components/index.ts b/apps/portal/src/features/get-started/components/index.ts new file mode 100644 index 00000000..37311086 --- /dev/null +++ b/apps/portal/src/features/get-started/components/index.ts @@ -0,0 +1,2 @@ +export { GetStartedForm } from "./GetStartedForm"; +export { OtpInput } from "./OtpInput"; diff --git a/apps/portal/src/features/get-started/index.ts b/apps/portal/src/features/get-started/index.ts new file mode 100644 index 00000000..8220e616 --- /dev/null +++ b/apps/portal/src/features/get-started/index.ts @@ -0,0 +1,25 @@ +/** + * Get Started Feature Module + * + * Unified flow for new customers: + * - Email verification (OTP) + * - Account status detection + * - Quick eligibility check + * - Account completion + */ + +// Views +export { GetStartedView } from "./views"; + +// Components +export { GetStartedForm, OtpInput } from "./components"; + +// Store +export { + useGetStartedStore, + type GetStartedStep, + type GetStartedState, +} from "./stores/get-started.store"; + +// API +export * as getStartedApi from "./api/get-started.api"; diff --git a/apps/portal/src/features/get-started/stores/get-started.store.ts b/apps/portal/src/features/get-started/stores/get-started.store.ts new file mode 100644 index 00000000..587b598c --- /dev/null +++ b/apps/portal/src/features/get-started/stores/get-started.store.ts @@ -0,0 +1,274 @@ +/** + * Get Started Store + * + * Manages state for the unified get-started flow (account creation). + * For eligibility check without account creation, see the Internet service page. + */ + +import { create } from "zustand"; +import { logger } from "@/core/logger"; +import { getErrorMessage } from "@/shared/utils"; +import type { AccountStatus, VerifyCodeResponse } from "@customer-portal/domain/get-started"; +import type { AuthResponse } from "@customer-portal/domain/auth"; +import * as api from "../api/get-started.api"; + +export type GetStartedStep = + | "email" + | "verification" + | "account-status" + | "complete-account" + | "success"; + +/** + * Address data format used in the get-started form + */ +export interface GetStartedAddress { + address1?: string; + address2?: string; + city?: string; + state?: string; + postcode?: string; + country?: string; + countryCode?: string; +} + +export interface GetStartedFormData { + email: string; + firstName: string; + lastName: string; + phone: string; + address: GetStartedAddress; + dateOfBirth: string; + gender: "male" | "female" | "other" | ""; + password: string; + confirmPassword: string; + acceptTerms: boolean; + marketingConsent: boolean; +} + +export interface GetStartedState { + // Current step + step: GetStartedStep; + + // Session + sessionToken: string | null; + emailVerified: boolean; + + // Account status (after verification) + accountStatus: AccountStatus | null; + + // Form data + formData: GetStartedFormData; + + // Prefill data from existing account + prefill: VerifyCodeResponse["prefill"] | null; + + // Loading and error states + loading: boolean; + error: string | null; + + // Verification state + codeSent: boolean; + attemptsRemaining: number | null; + resendDisabled: boolean; + + // Actions + sendVerificationCode: (email: string) => Promise; + verifyCode: (code: string) => Promise; + completeAccount: () => Promise; + + // Navigation + goToStep: (step: GetStartedStep) => void; + goBack: () => void; + + // Form updates + updateFormData: (data: Partial) => void; + + // Setters for handoff from eligibility check + setAccountStatus: (status: AccountStatus) => void; + setPrefill: (prefill: VerifyCodeResponse["prefill"]) => void; + setSessionToken: (token: string | null) => void; + + // Reset + reset: () => void; + clearError: () => void; +} + +const initialFormData: GetStartedFormData = { + email: "", + firstName: "", + lastName: "", + phone: "", + address: {}, + dateOfBirth: "", + gender: "", + password: "", + confirmPassword: "", + acceptTerms: false, + marketingConsent: false, +}; + +const initialState = { + step: "email" as GetStartedStep, + sessionToken: null, + emailVerified: false, + accountStatus: null, + formData: initialFormData, + prefill: null, + loading: false, + error: null, + codeSent: false, + attemptsRemaining: null, + resendDisabled: false, +}; + +export const useGetStartedStore = create()((set, get) => ({ + ...initialState, + + sendVerificationCode: async (email: string) => { + set({ loading: true, error: null }); + + try { + const result = await api.sendVerificationCode({ email }); + + if (result.sent) { + set({ + loading: false, + codeSent: true, + formData: { ...get().formData, email }, + step: "verification", + }); + return true; + } else { + set({ loading: false, error: result.message }); + return false; + } + } catch (error) { + const message = getErrorMessage(error); + logger.error("Failed to send verification code", { error: message }); + set({ loading: false, error: message }); + return false; + } + }, + + verifyCode: async (code: string) => { + set({ loading: true, error: null }); + + try { + const result = await api.verifyCode({ + email: get().formData.email, + code, + }); + + if (result.verified && result.sessionToken && result.accountStatus) { + // Apply prefill data if available + const prefill = result.prefill; + const currentFormData = get().formData; + + set({ + loading: false, + emailVerified: true, + sessionToken: result.sessionToken, + accountStatus: result.accountStatus, + prefill, + formData: { + ...currentFormData, + firstName: prefill?.firstName ?? currentFormData.firstName, + lastName: prefill?.lastName ?? currentFormData.lastName, + phone: prefill?.phone ?? currentFormData.phone, + address: prefill?.address ?? currentFormData.address, + }, + step: "account-status", + }); + + return result.accountStatus; + } else { + set({ + loading: false, + error: result.error ?? "Verification failed", + attemptsRemaining: result.attemptsRemaining ?? null, + }); + return null; + } + } catch (error) { + const message = getErrorMessage(error); + logger.error("Failed to verify code", { error: message }); + set({ loading: false, error: message }); + return null; + } + }, + + completeAccount: async () => { + const { sessionToken, formData } = get(); + + if (!sessionToken) { + set({ error: "Session expired. Please start over." }); + return null; + } + + set({ loading: true, error: null }); + + try { + const result = await api.completeAccount({ + sessionToken, + password: formData.password, + phone: formData.phone, + dateOfBirth: formData.dateOfBirth, + gender: formData.gender as "male" | "female" | "other", + acceptTerms: formData.acceptTerms, + marketingConsent: formData.marketingConsent, + }); + + set({ loading: false, step: "success" }); + + return result; + } catch (error) { + const message = getErrorMessage(error); + logger.error("Failed to complete account", { error: message }); + set({ loading: false, error: message }); + return null; + } + }, + + goToStep: (step: GetStartedStep) => { + set({ step, error: null }); + }, + + goBack: () => { + const { step } = get(); + const stepOrder: GetStartedStep[] = [ + "email", + "verification", + "account-status", + "complete-account", + ]; + const currentIndex = stepOrder.indexOf(step); + if (currentIndex > 0) { + set({ step: stepOrder[currentIndex - 1], error: null }); + } + }, + + updateFormData: (data: Partial) => { + set({ formData: { ...get().formData, ...data } }); + }, + + setAccountStatus: (status: AccountStatus) => { + set({ accountStatus: status }); + }, + + setPrefill: (prefill: VerifyCodeResponse["prefill"]) => { + set({ prefill }); + }, + + setSessionToken: (token: string | null) => { + set({ sessionToken: token, emailVerified: token !== null }); + }, + + reset: () => { + set(initialState); + }, + + clearError: () => { + set({ error: null }); + }, +})); diff --git a/apps/portal/src/features/get-started/views/GetStartedView.tsx b/apps/portal/src/features/get-started/views/GetStartedView.tsx new file mode 100644 index 00000000..38c1c38a --- /dev/null +++ b/apps/portal/src/features/get-started/views/GetStartedView.tsx @@ -0,0 +1,80 @@ +/** + * GetStartedView - Main view for the get-started flow + * + * Supports handoff from eligibility check flow: + * - URL params: ?email=xxx&verified=true + * - SessionStorage: get-started-email, get-started-verified + */ + +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { AuthLayout } from "@/components/templates/AuthLayout"; +import { GetStartedForm } from "../components"; +import { useGetStartedStore, type GetStartedStep } from "../stores/get-started.store"; + +export function GetStartedView() { + const searchParams = useSearchParams(); + const { updateFormData, goToStep, setAccountStatus, setPrefill, setSessionToken } = + useGetStartedStore(); + const [meta, setMeta] = useState({ + title: "Get Started", + subtitle: "Enter your email to begin", + }); + const [initialized, setInitialized] = useState(false); + + // Check for handoff from eligibility check on mount + useEffect(() => { + if (initialized) return; + + // Get params from URL or sessionStorage + const emailParam = searchParams.get("email"); + const verifiedParam = searchParams.get("verified"); + + const storedEmail = sessionStorage.getItem("get-started-email"); + const storedVerified = sessionStorage.getItem("get-started-verified"); + + // Clear sessionStorage after reading + sessionStorage.removeItem("get-started-email"); + sessionStorage.removeItem("get-started-verified"); + + const email = emailParam || storedEmail; + const isVerified = verifiedParam === "true" || storedVerified === "true"; + + if (email && isVerified) { + // User came from eligibility check - they have a verified email and SF Account + updateFormData({ email }); + // The email is verified, but we still need to check account status + // SF Account was already created during eligibility check, so status should be sf_unmapped + setAccountStatus("sf_unmapped"); + // Go directly to complete-account step + goToStep("complete-account"); + } + + setInitialized(true); + }, [ + initialized, + searchParams, + updateFormData, + goToStep, + setAccountStatus, + setPrefill, + setSessionToken, + ]); + + const handleStepChange = useCallback( + (_step: GetStartedStep, stepMeta: { title: string; subtitle: string }) => { + setMeta(stepMeta); + }, + [] + ); + + return ( + + + + ); +} + +export default GetStartedView; diff --git a/apps/portal/src/features/get-started/views/index.ts b/apps/portal/src/features/get-started/views/index.ts new file mode 100644 index 00000000..f78c5209 --- /dev/null +++ b/apps/portal/src/features/get-started/views/index.ts @@ -0,0 +1 @@ +export { GetStartedView } from "./GetStartedView"; diff --git a/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx new file mode 100644 index 00000000..d03575aa --- /dev/null +++ b/apps/portal/src/features/services/components/internet/EligibilityStatusBadge.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react"; +import { cn } from "@/shared/utils"; + +export type EligibilityStatus = "eligible" | "pending" | "not_requested" | "ineligible"; + +interface EligibilityStatusBadgeProps { + status: EligibilityStatus; + speed?: string; +} + +const STATUS_CONFIGS = { + eligible: { + icon: CheckCircle, + bg: "bg-success-soft", + border: "border-success/30", + text: "text-success", + label: "Service Available", + }, + pending: { + icon: Clock, + bg: "bg-info-soft", + border: "border-info/30", + text: "text-info", + label: "Review in Progress", + }, + not_requested: { + icon: MapPin, + bg: "bg-muted", + border: "border-border", + text: "text-muted-foreground", + label: "Verification Required", + }, + ineligible: { + icon: TriangleAlert, + bg: "bg-warning/10", + border: "border-warning/30", + text: "text-warning", + label: "Not Available", + }, +} as const; + +/** + * Displays the current eligibility status as a badge with icon. + * Used in the Internet Plans view to show user's eligibility state. + */ +export function EligibilityStatusBadge({ status, speed }: EligibilityStatusBadgeProps) { + const config = STATUS_CONFIGS[status]; + const Icon = config.icon; + + return ( +
+ + {config.label} + {status === "eligible" && speed && ( + <> + · + Up to {speed} + + )} +
+ ); +} diff --git a/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx b/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx new file mode 100644 index 00000000..d002ba0f --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetIneligibleState.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { TriangleAlert } from "lucide-react"; +import { Button } from "@/components/atoms/button"; + +interface InternetIneligibleStateProps { + rejectionNotes?: string | null; +} + +/** + * Displays the ineligible state when NTT service is not available at user's address. + */ +export function InternetIneligibleState({ rejectionNotes }: InternetIneligibleStateProps) { + return ( +
+ +

Service not available

+

+ {rejectionNotes || + "Our review determined that NTT fiber service isn't available at your address."} +

+ +
+ ); +} diff --git a/apps/portal/src/features/services/components/internet/InternetPendingState.tsx b/apps/portal/src/features/services/components/internet/InternetPendingState.tsx new file mode 100644 index 00000000..62d7843e --- /dev/null +++ b/apps/portal/src/features/services/components/internet/InternetPendingState.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Clock } from "lucide-react"; +import { Button } from "@/components/atoms/button"; +import { formatIsoDate } from "@/shared/utils"; + +interface InternetPendingStateProps { + requestedAt?: string | null; + servicesBasePath: string; +} + +/** + * Displays the pending verification state for internet eligibility. + * Shown when user has requested eligibility check but it's still being processed. + */ +export function InternetPendingState({ requestedAt, servicesBasePath }: InternetPendingStateProps) { + return ( + <> +
+ +

Verification in Progress

+

+ We're currently verifying NTT service availability at your registered address. +
+ This manual check ensures we offer you the correct fiber connection type. +

+ +
+ Estimated time + 1-2 business days +
+ + {requestedAt && ( +

+ Request submitted: {formatIsoDate(requestedAt)} +

+ )} +
+ +
+ +
+ + ); +} diff --git a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx index 77659a11..d1f397fc 100644 --- a/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx +++ b/apps/portal/src/features/services/components/vpn/VpnPlanCard.tsx @@ -6,74 +6,14 @@ import { ArrowRight, Check, ShieldCheck } from "lucide-react"; import type { VpnCatalogProduct } from "@customer-portal/domain/services"; import { CardPricing } from "@/features/services/components/base/CardPricing"; import { cn } from "@/shared/utils/cn"; +import { getVpnRegionConfig } from "@/features/services/utils"; interface VpnPlanCardProps { plan: VpnCatalogProduct; } -// Region-specific data -const regionData: Record< - string, - { - flag: string; - flagAlt: string; - location: string; - features: string[]; - accent: string; - } -> = { - "San Francisco": { - flag: "🇺🇸", - flagAlt: "US Flag", - location: "United States", - features: [ - "Access US streaming content", - "Optimized for Netflix, Hulu, HBO", - "West Coast US server", - "Low latency for streaming", - ], - accent: "blue", - }, - London: { - flag: "🇬🇧", - flagAlt: "UK Flag", - location: "United Kingdom", - features: [ - "Access UK streaming content", - "Optimized for BBC iPlayer, ITV", - "London-based server", - "European content access", - ], - accent: "red", - }, -}; - -// Fallback for unknown regions -const defaultRegionData = { - flag: "🌐", - flagAlt: "Globe", - location: "International", - features: [ - "Secure VPN connection", - "Pre-configured router", - "Easy plug & play setup", - "English support included", - ], - accent: "primary", -}; - -function getRegionData(planName: string) { - // Try to match known regions - for (const [region, data] of Object.entries(regionData)) { - if (planName.toLowerCase().includes(region.toLowerCase())) { - return { region, ...data }; - } - } - return { region: planName, ...defaultRegionData }; -} - export function VpnPlanCard({ plan }: VpnPlanCardProps) { - const region = getRegionData(plan.name); + const region = getVpnRegionConfig(plan.name); const isUS = region.accent === "blue"; const isUK = region.accent === "red"; diff --git a/apps/portal/src/features/services/utils/index.ts b/apps/portal/src/features/services/utils/index.ts index 317073c5..4c8300b0 100644 --- a/apps/portal/src/features/services/utils/index.ts +++ b/apps/portal/src/features/services/utils/index.ts @@ -1,2 +1,4 @@ export * from "./services.utils"; export * from "./pricing"; +export * from "./internet-config"; +export * from "./service-features"; diff --git a/apps/portal/src/features/services/utils/internet-config.ts b/apps/portal/src/features/services/utils/internet-config.ts new file mode 100644 index 00000000..055615fe --- /dev/null +++ b/apps/portal/src/features/services/utils/internet-config.ts @@ -0,0 +1,339 @@ +/** + * Internet Service Configuration + * + * Centralized configuration for internet service tiers, offerings, and features. + * This module eliminates duplication between InternetPlans.tsx and PublicInternetPlans.tsx. + */ + +// ============================================================================ +// Types +// ============================================================================ + +export type InternetTier = "Silver" | "Gold" | "Platinum"; + +export interface TierConfig { + description: string; + features: string[]; + pricingNote?: string; + isRecommended?: boolean; +} + +export interface OfferingConfig { + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + isPremium: boolean; + displayOrder: number; + isAlternative?: boolean; + alternativeNote?: string; +} + +// ============================================================================ +// Tier Configuration +// ============================================================================ + +/** + * Tier ordering for consistent display across all internet views + */ +export const TIER_ORDER: InternetTier[] = ["Silver", "Gold", "Platinum"]; + +/** + * Tier order lookup for sorting operations + */ +export const TIER_ORDER_MAP: Record = { + Silver: 0, + Gold: 1, + Platinum: 2, +}; + +/** + * Tier configurations with descriptions and features. + * Used in both authenticated (InternetPlans) and public (PublicInternetPlans) views. + */ +export const TIER_CONFIGS: Record = { + Silver: { + description: "Essential setup—bring your own router", + features: ["NTT modem + ISP connection", "IPoE or PPPoE protocols", "Self-configuration"], + isRecommended: false, + }, + Gold: { + description: "All-inclusive with router rental", + features: [ + "Everything in Silver", + "WiFi router included", + "Auto-configured", + "Range extender option", + ], + isRecommended: true, + }, + Platinum: { + description: "Tailored setup for larger homes", + features: [ + "Netgear INSIGHT mesh routers", + "Cloud-managed WiFi", + "Remote support", + "Custom setup", + ], + pricingNote: "+ equipment fees", + isRecommended: false, + }, +}; + +/** + * Public-facing tier descriptions (shorter, marketing-focused) + */ +export const PUBLIC_TIER_DESCRIPTIONS: Record = { + Silver: "Use your own router. Best for tech-savvy users.", + Gold: "Includes WiFi router rental. Our most popular choice.", + Platinum: "Premium equipment with mesh WiFi for larger homes.", +}; + +/** + * Public-facing tier features (shorter list for marketing) + */ +export const PUBLIC_TIER_FEATURES: Record = { + Silver: ["NTT modem + ISP connection", "Use your own router", "Email/ticket support"], + Gold: ["NTT modem + ISP connection", "WiFi router included", "Priority phone support"], + Platinum: ["NTT modem + ISP connection", "Mesh WiFi system included", "Dedicated support line"], +}; + +// ============================================================================ +// Offering Configuration +// ============================================================================ + +/** + * Internet offering configurations by type. + * Defines display properties for each offering type. + */ +export const OFFERING_CONFIGS: Record> = { + "Home 10G": { + title: "Home 10Gbps", + speedBadge: "10 Gbps", + description: "Ultra-fast fiber with the highest speeds available in Japan.", + iconType: "home", + isPremium: true, + displayOrder: 1, + }, + "Home 1G": { + title: "Home 1Gbps", + speedBadge: "1 Gbps", + description: "High-speed fiber. The most popular choice for home internet.", + iconType: "home", + isPremium: false, + displayOrder: 2, + }, + "Apartment 1G": { + title: "Apartment 1Gbps", + speedBadge: "1 Gbps", + description: "High-speed fiber-to-the-unit for mansions and apartment buildings.", + iconType: "apartment", + isPremium: false, + displayOrder: 1, + }, + "Apartment 100M": { + title: "Apartment 100Mbps", + speedBadge: "100 Mbps", + description: "Standard speed via VDSL or LAN for apartment buildings.", + iconType: "apartment", + isPremium: false, + displayOrder: 2, + }, + Apartment: { + title: "Apartment", + speedBadge: "Up to 1Gbps", + description: + "For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).", + iconType: "apartment", + isPremium: false, + displayOrder: 3, + }, +}; + +/** + * Speed badge lookup by offering type + */ +export const SPEED_BADGES: Record = { + "Apartment 100M": "100Mbps", + "Apartment 1G": "1Gbps", + "Home 1G": "1Gbps", + "Home 10G": "10Gbps", +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get tier description for display + */ +export function getTierDescription(tier: string, usePublicVersion = false): string { + const normalizedTier = tier as InternetTier; + if (usePublicVersion) { + return PUBLIC_TIER_DESCRIPTIONS[normalizedTier] ?? ""; + } + return TIER_CONFIGS[normalizedTier]?.description ?? ""; +} + +/** + * Get tier features for display + */ +export function getTierFeatures(tier: string, usePublicVersion = false): string[] { + const normalizedTier = tier as InternetTier; + if (usePublicVersion) { + return PUBLIC_TIER_FEATURES[normalizedTier] ?? []; + } + return TIER_CONFIGS[normalizedTier]?.features ?? []; +} + +/** + * Get speed badge for offering type + */ +export function getSpeedBadge(offeringType: string): string { + return SPEED_BADGES[offeringType] ?? "1Gbps"; +} + +/** + * Get full tier config + */ +export function getTierConfig(tier: string): TierConfig | undefined { + return TIER_CONFIGS[tier as InternetTier]; +} + +/** + * Get offering config by type + */ +export function getOfferingConfig( + offeringType: string +): Omit | undefined { + return OFFERING_CONFIGS[offeringType]; +} + +/** + * Check if a tier is the recommended tier + */ +export function isRecommendedTier(tier: string): boolean { + return tier === "Gold"; +} + +/** + * Sort tiers by the standard order + */ +export function sortTiers(plans: T[]): T[] { + return [...plans].sort( + (a, b) => + (TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) - + (TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99) + ); +} + +// ============================================================================ +// Eligibility Display Configuration +// ============================================================================ + +export interface EligibilityDisplayInfo { + residenceType: "home" | "apartment"; + speed: string; + label: string; + description: string; +} + +/** + * Eligibility display configuration by type + */ +const ELIGIBILITY_DISPLAY: Record = { + "home 10g": { + residenceType: "home", + speed: "10 Gbps", + label: "Standalone House (10Gbps available)", + description: + "Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.", + }, + "home 1g": { + residenceType: "home", + speed: "1 Gbps", + label: "Standalone House (1Gbps)", + description: "Your address supports high-speed 1Gbps fiber connection.", + }, + "apartment 1g": { + residenceType: "apartment", + speed: "1 Gbps", + label: "Apartment/Mansion (1Gbps FTTH)", + description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.", + }, + "apartment 100m": { + residenceType: "apartment", + speed: "100 Mbps", + label: "Apartment/Mansion (100Mbps)", + description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.", + }, +}; + +/** + * Format eligibility string into display information + */ +export function formatEligibilityDisplay(eligibility: string): EligibilityDisplayInfo { + const lower = eligibility.toLowerCase(); + + // Check for known eligibility types + for (const [key, info] of Object.entries(ELIGIBILITY_DISPLAY)) { + if (lower.includes(key)) { + return info; + } + } + + // Default fallback + return { + residenceType: "home", + speed: eligibility, + label: eligibility, + description: "Service is available at your address.", + }; +} + +/** + * Get available offerings based on eligibility and catalog plans + */ +export function getAvailableOfferings( + eligibility: string | null, + plans: T[] +): (OfferingConfig & { offeringType: string })[] { + if (!eligibility) return []; + + const results: (OfferingConfig & { offeringType: string })[] = []; + const eligibilityLower = eligibility.toLowerCase(); + + if (eligibilityLower.includes("home 10g")) { + const config10g = OFFERING_CONFIGS["Home 10G"]; + const config1g = OFFERING_CONFIGS["Home 1G"]; + if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) { + results.push({ offeringType: "Home 10G", ...config10g }); + } + if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) { + results.push({ + offeringType: "Home 1G", + ...config1g, + isAlternative: true, + alternativeNote: "Lower monthly cost option", + }); + } + } else if (eligibilityLower.includes("home 1g")) { + const config = OFFERING_CONFIGS["Home 1G"]; + if (config && plans.some(p => p.internetOfferingType === "Home 1G")) { + results.push({ offeringType: "Home 1G", ...config }); + } + } else if (eligibilityLower.includes("apartment 1g")) { + const config = OFFERING_CONFIGS["Apartment 1G"]; + if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) { + results.push({ offeringType: "Apartment 1G", ...config }); + } + } else if (eligibilityLower.includes("apartment 100m")) { + const config = OFFERING_CONFIGS["Apartment 100M"]; + if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) { + results.push({ offeringType: "Apartment 100M", ...config }); + } + } + + return results.sort((a, b) => a.displayOrder - b.displayOrder); +} diff --git a/apps/portal/src/features/services/utils/service-features.tsx b/apps/portal/src/features/services/utils/service-features.tsx new file mode 100644 index 00000000..cbb9e101 --- /dev/null +++ b/apps/portal/src/features/services/utils/service-features.tsx @@ -0,0 +1,197 @@ +/** + * Service Features Configuration + * + * Centralized feature lists and marketing content for all service types. + * This eliminates duplication across PublicInternetPlans, PublicVpnPlans, and VpnPlanCard. + */ + +import { + Wifi, + Zap, + Languages, + FileText, + Wrench, + Globe, + Router, + Headphones, + Package, + MonitorPlay, +} from "lucide-react"; +import type { ReactNode } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface HighlightFeature { + icon: ReactNode; + title: string; + description: string; + highlight: string; +} + +export interface VpnRegionConfig { + flag: string; + flagAlt: string; + location: string; + features: string[]; + accent: "blue" | "red" | "primary"; +} + +// ============================================================================ +// Internet Features +// ============================================================================ + +/** + * Internet service highlight features for marketing pages + */ +export const INTERNET_FEATURES: HighlightFeature[] = [ + { + icon: , + title: "NTT Optical Fiber", + description: "Japan's most reliable network with speeds up to 10Gbps", + highlight: "99.9% uptime", + }, + { + icon: , + title: "IPv6/IPoE Ready", + description: "Next-gen protocol for congestion-free browsing", + highlight: "No peak-hour slowdowns", + }, + { + icon: , + title: "Full English Support", + description: "Native English service for setup, billing & technical help", + highlight: "No language barriers", + }, + { + icon: , + title: "One Bill, One Provider", + description: "NTT line + ISP + equipment bundled with simple billing", + highlight: "No hidden fees", + }, + { + icon: , + title: "On-site Support", + description: "Technicians can visit for installation & troubleshooting", + highlight: "Professional setup", + }, + { + icon: , + title: "Flexible Options", + description: "Multiple ISP configs available, IPv4/PPPoE if needed", + highlight: "Customizable", + }, +]; + +// ============================================================================ +// VPN Features +// ============================================================================ + +/** + * VPN service highlight features for marketing pages + */ +export const VPN_FEATURES: HighlightFeature[] = [ + { + icon: , + title: "Pre-configured Router", + description: "Ready to use out of the box — just plug in and connect", + highlight: "Plug & play", + }, + { + icon: , + title: "US & UK Servers", + description: "Access content from San Francisco or London regions", + highlight: "2 locations", + }, + { + icon: , + title: "Streaming Ready", + description: "Works with Apple TV, Roku, Amazon Fire, and more", + highlight: "All devices", + }, + { + icon: , + title: "Separate Network", + description: "VPN runs on dedicated WiFi, keep regular internet normal", + highlight: "No interference", + }, + { + icon: , + title: "Router Rental Included", + description: "No equipment purchase — router rental is part of the plan", + highlight: "No hidden costs", + }, + { + icon: , + title: "English Support", + description: "Full English assistance for setup and troubleshooting", + highlight: "Dedicated help", + }, +]; + +// ============================================================================ +// VPN Region Configuration +// ============================================================================ + +/** + * VPN region-specific configuration + */ +export const VPN_REGIONS: Record = { + "San Francisco": { + flag: "🇺🇸", + flagAlt: "US Flag", + location: "United States", + features: [ + "Access US streaming content", + "Optimized for Netflix, Hulu, HBO", + "West Coast US server", + "Low latency for streaming", + ], + accent: "blue", + }, + London: { + flag: "🇬🇧", + flagAlt: "UK Flag", + location: "United Kingdom", + features: [ + "Access UK streaming content", + "Optimized for BBC iPlayer, ITV", + "London-based server", + "European content access", + ], + accent: "red", + }, +}; + +/** + * Default/fallback VPN region configuration + */ +export const DEFAULT_VPN_REGION: VpnRegionConfig = { + flag: "🌐", + flagAlt: "Globe", + location: "International", + features: [ + "Secure VPN connection", + "Pre-configured router", + "Easy plug & play setup", + "English support included", + ], + accent: "primary", +}; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get VPN region configuration from plan name + */ +export function getVpnRegionConfig(planName: string): VpnRegionConfig & { region: string } { + for (const [region, config] of Object.entries(VPN_REGIONS)) { + if (planName.toLowerCase().includes(region.toLowerCase())) { + return { region, ...config }; + } + } + return { region: planName, ...DEFAULT_VPN_REGION }; +} diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx index 78ddea39..e6f3e127 100644 --- a/apps/portal/src/features/services/views/InternetPlans.tsx +++ b/apps/portal/src/features/services/views/InternetPlans.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react"; +import { Server } from "lucide-react"; import { useAccountInternetCatalog } from "@/features/services/hooks"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import type { @@ -12,7 +12,6 @@ import type { import { Skeleton } from "@/components/atoms/loading-skeleton"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { Button } from "@/components/atoms/button"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes"; @@ -22,103 +21,37 @@ import { } from "@/features/services/components/internet/InternetOfferingCard"; import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans"; import { PlanComparisonGuide } from "@/features/services/components/internet/PlanComparisonGuide"; +import { EligibilityStatusBadge } from "@/features/services/components/internet/EligibilityStatusBadge"; +import { InternetPendingState } from "@/features/services/components/internet/InternetPendingState"; +import { InternetIneligibleState } from "@/features/services/components/internet/InternetIneligibleState"; import { useInternetEligibility } from "@/features/services/hooks"; import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store"; -import { cn, formatIsoDate } from "@/shared/utils"; +import { + TIER_ORDER, + TIER_CONFIGS, + formatEligibilityDisplay, + getAvailableOfferings, +} from "@/features/services/utils"; -// Offering configuration for display -interface OfferingConfig { - offeringType: string; - title: string; - speedBadge: string; - description: string; - iconType: "home" | "apartment"; - isPremium: boolean; - displayOrder: number; - isAlternative?: boolean; - alternativeNote?: string; -} - -const OFFERING_CONFIGS: Record> = { - "Home 10G": { - title: "Home 10Gbps", - speedBadge: "10 Gbps", - description: "Ultra-fast fiber with the highest speeds available in Japan.", - iconType: "home", - isPremium: true, - displayOrder: 1, - }, - "Home 1G": { - title: "Home 1Gbps", - speedBadge: "1 Gbps", - description: "High-speed fiber. The most popular choice for home internet.", - iconType: "home", - isPremium: false, - displayOrder: 2, - }, - "Apartment 1G": { - title: "Apartment 1Gbps", - speedBadge: "1 Gbps", - description: "High-speed fiber-to-the-unit for mansions and apartment buildings.", - iconType: "apartment", - isPremium: false, - displayOrder: 1, - }, - "Apartment 100M": { - title: "Apartment 100Mbps", - speedBadge: "100 Mbps", - description: "Standard speed via VDSL or LAN for apartment buildings.", - iconType: "apartment", - isPremium: false, - displayOrder: 2, - }, -}; +// ============================================================================ +// Helper Functions +// ============================================================================ function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] { const filtered = plans.filter(p => p.internetOfferingType === offeringType); - const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"]; - - const tierDescriptions: Record< - string, - { description: string; features: string[]; pricingNote?: string } - > = { - Silver: { - description: "Essential setup—bring your own router", - features: ["NTT modem + ISP connection", "IPoE or PPPoE protocols", "Self-configuration"], - }, - Gold: { - description: "All-inclusive with router rental", - features: [ - "Everything in Silver", - "WiFi router included", - "Auto-configured", - "Range extender option", - ], - }, - Platinum: { - description: "Tailored setup for larger homes", - features: [ - "Netgear INSIGHT mesh routers", - "Cloud-managed WiFi", - "Remote support", - "Custom setup", - ], - pricingNote: "+ equipment fees", - }, - }; const result: TierInfo[] = []; - for (const tier of tierOrder) { + for (const tier of TIER_ORDER) { const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase()); if (!plan) continue; - const config = tierDescriptions[tier]; + const config = TIER_CONFIGS[tier]; result.push({ tier, planSku: plan.sku, monthlyPrice: plan.monthlyPrice ?? 0, description: config.description, features: config.features, - recommended: tier === "Gold", + recommended: config.isRecommended ?? false, pricingNote: config.pricingNote, }); } @@ -130,177 +63,186 @@ function getSetupFee(installations: InternetInstallationCatalogItem[]): number { return basic?.oneTimePrice ?? 22800; } -function getAvailableOfferings( - eligibility: string | null, - plans: InternetPlanCatalogItem[] -): OfferingConfig[] { - if (!eligibility) return []; - - const results: OfferingConfig[] = []; - const eligibilityLower = eligibility.toLowerCase(); - - if (eligibilityLower.includes("home 10g")) { - const config10g = OFFERING_CONFIGS["Home 10G"]; - const config1g = OFFERING_CONFIGS["Home 1G"]; - if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) { - results.push({ offeringType: "Home 10G", ...config10g }); - } - if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) { - results.push({ - offeringType: "Home 1G", - ...config1g, - isAlternative: true, - alternativeNote: "Lower monthly cost option", - }); - } - } else if (eligibilityLower.includes("home 1g")) { - const config = OFFERING_CONFIGS["Home 1G"]; - if (config && plans.some(p => p.internetOfferingType === "Home 1G")) { - results.push({ offeringType: "Home 1G", ...config }); - } - } else if (eligibilityLower.includes("apartment 1g")) { - const config = OFFERING_CONFIGS["Apartment 1G"]; - if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) { - results.push({ offeringType: "Apartment 1G", ...config }); - } - } else if (eligibilityLower.includes("apartment 100m")) { - const config = OFFERING_CONFIGS["Apartment 100M"]; - if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) { - results.push({ offeringType: "Apartment 100M", ...config }); - } - } - - return results.sort((a, b) => a.displayOrder - b.displayOrder); -} - -function formatEligibilityDisplay(eligibility: string): { - residenceType: "home" | "apartment"; - speed: string; - label: string; - description: string; -} { - const lower = eligibility.toLowerCase(); - - if (lower.includes("home 10g")) { - return { - residenceType: "home", - speed: "10 Gbps", - label: "Standalone House (10Gbps available)", - description: - "Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.", - }; - } - if (lower.includes("home 1g")) { - return { - residenceType: "home", - speed: "1 Gbps", - label: "Standalone House (1Gbps)", - description: "Your address supports high-speed 1Gbps fiber connection.", - }; - } - if (lower.includes("apartment 1g")) { - return { - residenceType: "apartment", - speed: "1 Gbps", - label: "Apartment/Mansion (1Gbps FTTH)", - description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.", - }; - } - if (lower.includes("apartment 100m")) { - return { - residenceType: "apartment", - speed: "100 Mbps", - label: "Apartment/Mansion (100Mbps)", - description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.", - }; - } - - return { - residenceType: "home", - speed: eligibility, - label: eligibility, - description: "Service is available at your address.", - }; -} - -// Status badge component -function EligibilityStatusBadge({ - status, - speed, -}: { - status: "eligible" | "pending" | "not_requested" | "ineligible"; - speed?: string; -}) { - const configs = { - eligible: { - icon: CheckCircle, - bg: "bg-success-soft", - border: "border-success/30", - text: "text-success", - label: "Service Available", - }, - pending: { - icon: Clock, - bg: "bg-info-soft", - border: "border-info/30", - text: "text-info", - label: "Review in Progress", - }, - not_requested: { - icon: MapPin, - bg: "bg-muted", - border: "border-border", - text: "text-muted-foreground", - label: "Verification Required", - }, - ineligible: { - icon: TriangleAlert, - bg: "bg-warning/10", - border: "border-warning/30", - text: "text-warning", - label: "Not Available", - }, - }; - - const config = configs[status]; - const Icon = config.icon; +// ============================================================================ +// Loading Skeleton Component +// ============================================================================ +function InternetPlansLoadingSkeleton({ servicesBasePath }: { servicesBasePath: string }) { return ( -
- - {config.label} - {status === "eligible" && speed && ( - <> - · - Up to {speed} - - )} +
+ +
+ + +
+
+ {[1, 2].map(i => ( +
+
+ +
+ + + +
+
+
+ ))} +
); } +// ============================================================================ +// Eligible State Component +// ============================================================================ + +interface EligibleStateProps { + offeringCards: Array<{ + offeringType: string; + title: string; + speedBadge: string; + description: string; + iconType: "home" | "apartment"; + isPremium: boolean; + isAlternative?: boolean; + alternativeNote?: string; + tiers: TierInfo[]; + startingPrice: number; + setupFee: number; + ctaPath: string; + }>; + hasActiveInternet: boolean; + servicesBasePath: string; +} + +function InternetEligibleState({ + offeringCards, + hasActiveInternet, + servicesBasePath, +}: EligibleStateProps) { + return ( + <> + {/* Plan comparison guide */} +
+ +
+ + {/* Speed options header (only if multiple) */} + {offeringCards.length > 1 && ( +
+

Choose your speed

+

Your address supports multiple options

+
+ )} + + {/* Offering cards */} +
+ {offeringCards.map(card => ( +
+ {card.isAlternative && ( +
+
+ + Alternative option + +
+
+ )} + +
+ ))} +
+ + {/* Important notes - collapsed by default */} + + + + + ); +} + +// ============================================================================ +// No Plans Available Component +// ============================================================================ + +function NoPlansAvailable({ servicesBasePath }: { servicesBasePath: string }) { + return ( +
+
+ +

No Plans Available

+

+ We couldn't find any internet plans at this time. +

+ +
+
+ ); +} + +// ============================================================================ +// Active Internet Warning Component +// ============================================================================ + +function ActiveInternetWarning() { + return ( + + You already have an internet subscription. For additional residences, please{" "} + + contact support + + . + + ); +} + +// ============================================================================ +// Main Container Component +// ============================================================================ + export function InternetPlansContainer() { const router = useRouter(); const servicesBasePath = useServicesBasePath(); const searchParams = useSearchParams(); const { user } = useAuthSession(); const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); + + // Data fetching const { data, error } = useAccountInternetCatalog(); - // Simple loading check: show skeleton until we have data or an error const isLoading = !data && !error; const eligibilityQuery = useInternetEligibility(); const eligibilityLoading = eligibilityQuery.isLoading; - const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]); - const installations: InternetInstallationCatalogItem[] = useMemo( - () => data?.installations ?? [], - [data?.installations] - ); + + // Memoized data + const plans = useMemo(() => data?.plans ?? [], [data?.plans]); + const installations = useMemo(() => data?.installations ?? [], [data?.installations]); + + // Check for active subscriptions const { data: activeSubs } = useActiveSubscriptions(); const hasActiveInternet = useMemo( () => @@ -316,6 +258,7 @@ export function InternetPlansContainer() { [activeSubs] ); + // Eligibility state const eligibilityValue = eligibilityQuery.data?.eligibility; const eligibilityStatus = eligibilityQuery.data?.status; const requestedAt = eligibilityQuery.data?.requestedAt; @@ -329,9 +272,11 @@ export function InternetPlansContainer() { const isNotRequested = eligibilityStatus === "not_requested"; const isIneligible = eligibilityStatus === "ineligible"; + // URL params for auto-redirect const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1"; const autoPlanSku = searchParams?.get("planSku"); + // Computed values const eligibility = useMemo(() => { if (!isEligible) return null; return eligibilityValue?.trim() ?? null; @@ -365,18 +310,15 @@ export function InternetPlansContainer() { .filter(card => card.tiers.length > 0); }, [availableOfferings, plans, setupFee, servicesBasePath]); - // Logic to handle check availability click - const handleCheckAvailability = async (e?: React.MouseEvent) => { + // Handlers + const handleCheckAvailability = (e?: React.MouseEvent) => { if (e) e.preventDefault(); - const target = `${servicesBasePath}/internet/request`; - router.push(target); + router.push(`${servicesBasePath}/internet/request`); }; // Auto eligibility request redirect useEffect(() => { - if (!autoEligibilityRequest) return; - if (!hasCheckedAuth) return; - if (!user) return; + if (!autoEligibilityRequest || !hasCheckedAuth || !user) return; const params = new URLSearchParams(); if (autoPlanSku) params.set("planSku", autoPlanSku); @@ -385,40 +327,6 @@ export function InternetPlansContainer() { router.replace(`${servicesBasePath}/internet/request${query.length > 0 ? `?${query}` : ""}`); }, [autoEligibilityRequest, autoPlanSku, hasCheckedAuth, servicesBasePath, user, router]); - // Loading state - if (isLoading || error) { - return ( -
- -
- -
- - -
-
- {[1, 2].map(i => ( -
-
- -
- - - -
-
-
- ))} -
-
-
-
- ); - } - // Determine current status for the badge const currentStatus = isEligible ? "eligible" @@ -428,21 +336,22 @@ export function InternetPlansContainer() { ? "ineligible" : "not_requested"; - // Case 1: Unverified / Not Requested - Show Public Content exactly + // Loading state + if (isLoading || error) { + return ( +
+ + + +
+ ); + } + + // Not Requested - Show Public Content if (isNotRequested) { return (
- {/* Already has internet warning */} - {hasActiveInternet && ( - - You already have an internet subscription. For additional residences, please{" "} - - contact support - - . - - )} - + {hasActiveInternet && } - {/* Hero section - compact (for portal view) */} + {/* Hero section */}

Your Internet Options @@ -470,7 +379,7 @@ export function InternetPlansContainer() { )} - {/* Loading states */} + {/* Loading state */} {eligibilityLoading && (
@@ -479,143 +388,27 @@ export function InternetPlansContainer() { )}
- {/* Already has internet warning */} - {hasActiveInternet && ( - - You already have an internet subscription. For additional residences, please{" "} - - contact support - - . - - )} + {hasActiveInternet && } - {/* ELIGIBLE STATE - Clean & Personalized */} + {/* Eligible State */} {isEligible && eligibilityDisplay && offeringCards.length > 0 && ( - <> - {/* Plan comparison guide */} -
- -
- - {/* Speed options header (only if multiple) */} - {offeringCards.length > 1 && ( -
-

Choose your speed

-

- Your address supports multiple options -

-
- )} - - {/* Offering cards */} -
- {offeringCards.map(card => ( -
- {card.isAlternative && ( -
-
- - Alternative option - -
-
- )} - -
- ))} -
- - {/* Important notes - collapsed by default */} - - - - + )} - {/* PENDING STATE - Clean Status View */} + {/* Pending State */} {isPending && ( - <> -
- -

- Verification in Progress -

-

- We're currently verifying NTT service availability at your registered address. -
- This manual check ensures we offer you the correct fiber connection type. -

- -
- Estimated time - 1-2 business days -
- - {requestedAt && ( -

- Request submitted: {formatIsoDate(requestedAt)} -

- )} -
- -
- -
- + )} - {/* INELIGIBLE STATE */} - {isIneligible && ( -
- -

Service not available

-

- {rejectionNotes || - "Our review determined that NTT fiber service isn't available at your address."} -

- -
- )} + {/* Ineligible State */} + {isIneligible && } {/* No plans available */} - {plans.length === 0 && !isLoading && ( -
-
- -

No Plans Available

-

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

- -
-
- )} + {plans.length === 0 && !isLoading && }
); } diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx index 74111fd2..3724259d 100644 --- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx +++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx @@ -1,18 +1,7 @@ "use client"; import { useMemo } from "react"; -import { - Wifi, - Zap, - Languages, - FileText, - Wrench, - Globe, - MapPin, - Settings, - Calendar, - Router, -} from "lucide-react"; +import { Wifi, MapPin, Settings, Calendar, Router } from "lucide-react"; import { usePublicInternetCatalog } from "@/features/services/hooks"; import type { InternetPlanCatalogItem, @@ -24,13 +13,18 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard"; import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard"; -import { - ServiceHighlights, - HighlightFeature, -} from "@/features/services/components/base/ServiceHighlights"; +import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights"; import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks"; import { ServiceCTA } from "@/features/services/components/base/ServiceCTA"; import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ"; +import { + OFFERING_CONFIGS, + TIER_ORDER_MAP, + getSpeedBadge, + getTierDescription, + getTierFeatures, + INTERNET_FEATURES, +} from "@/features/services/utils"; // Types interface GroupedOffering { @@ -129,45 +123,6 @@ export function PublicInternetPlansContent({ const defaultCtaPath = `${servicesBasePath}/internet/configure`; const ctaPath = propCtaPath ?? defaultCtaPath; - const internetFeatures: HighlightFeature[] = [ - { - icon: , - title: "NTT Optical Fiber", - description: "Japan's most reliable network with speeds up to 10Gbps", - highlight: "99.9% uptime", - }, - { - icon: , - title: "IPv6/IPoE Ready", - description: "Next-gen protocol for congestion-free browsing", - highlight: "No peak-hour slowdowns", - }, - { - icon: , - title: "Full English Support", - description: "Native English service for setup, billing & technical help", - highlight: "No language barriers", - }, - { - icon: , - title: "One Bill, One Provider", - description: "NTT line + ISP + equipment bundled with simple billing", - highlight: "No hidden fees", - }, - { - icon: , - title: "On-site Support", - description: "Technicians can visit for installation & troubleshooting", - highlight: "Professional setup", - }, - { - icon: , - title: "Flexible Options", - description: "Multiple ISP configs available, IPv4/PPPoE if needed", - highlight: "Customizable", - }, - ]; - // Group services items by offering type const groupedOfferings = useMemo(() => { if (!servicesCatalog?.plans) return []; @@ -202,38 +157,11 @@ export function PublicInternetPlansContent({ } } - // Define offering metadata - // Order: Home 10G first (premium), then Home 1G, then consolidated Apartment - const offeringMeta: Record< - string, - { - title: string; - description: string; - iconType: "home" | "apartment"; - order: number; - isPremium?: boolean; - } - > = { - "Home 10G": { - title: "Home 10Gbps", - description: "Ultra-fast fiber with the highest speeds available in Japan.", - iconType: "home", - order: 1, - isPremium: true, - }, - "Home 1G": { - title: "Home 1Gbps", - description: "High-speed fiber. The most popular choice for home internet.", - iconType: "home", - order: 2, - }, - Apartment: { - title: "Apartment", - description: - "For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).", - iconType: "apartment", - order: 3, - }, + // Use shared offering configs with display order override for public view + const offeringDisplayOrder: Record = { + "Home 10G": 1, + "Home 1G": 2, + Apartment: 3, }; // Process Home offerings @@ -241,28 +169,29 @@ export function PublicInternetPlansContent({ // Skip apartment types - we'll handle them separately if (apartmentTypes.includes(offeringType)) continue; - const meta = offeringMeta[offeringType]; + const meta = OFFERING_CONFIGS[offeringType]; if (!meta) continue; - // Sort plans by tier: Silver, Gold, Platinum - const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; + // Sort plans by tier using shared tier order const sortedPlans = [...plans].sort( (a, b) => - (tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99) + (TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) - + (TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99) ); // Calculate starting price const startingPrice = Math.min(...sortedPlans.map(p => p.monthlyPrice ?? 0)); - // Get speed from offering type + // Get speed from offering type using shared utility const speedBadge = getSpeedBadge(offeringType); - // Build tier info (no recommended badge in public view) + // Build tier info using shared tier descriptions/features (public version) const tiers: TierInfo[] = sortedPlans.map(plan => ({ tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], monthlyPrice: plan.monthlyPrice ?? 0, - description: getTierDescription(plan.internetPlanTier ?? ""), - features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), + description: getTierDescription(plan.internetPlanTier ?? "", true), + features: + plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true), pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, })); @@ -281,10 +210,9 @@ export function PublicInternetPlansContent({ // Add consolidated Apartment offering (use any apartment plan for tiers - prices are the same) if (apartmentPlans.length > 0) { - const meta = offeringMeta["Apartment"]; + const meta = OFFERING_CONFIGS["Apartment"]; // Get unique tiers from apartment plans (they all have same prices) - const tierOrder: Record = { Silver: 0, Gold: 1, Platinum: 2 }; const uniqueTiers = new Map(); for (const plan of apartmentPlans) { @@ -297,7 +225,8 @@ export function PublicInternetPlansContent({ const sortedTierPlans = Array.from(uniqueTiers.values()).sort( (a, b) => - (tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99) + (TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) - + (TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99) ); const startingPrice = Math.min(...sortedTierPlans.map(p => p.monthlyPrice ?? 0)); @@ -305,17 +234,20 @@ export function PublicInternetPlansContent({ const tiers: TierInfo[] = sortedTierPlans.map(plan => ({ tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], monthlyPrice: plan.monthlyPrice ?? 0, - description: getTierDescription(plan.internetPlanTier ?? ""), - features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), + description: getTierDescription(plan.internetPlanTier ?? "", true), + features: + plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true), pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, })); offerings.push({ offeringType: "Apartment", - title: meta.title, - speedBadge: "Up to 1Gbps", - description: meta.description, - iconType: meta.iconType, + title: meta?.title ?? "Apartment", + speedBadge: meta?.speedBadge ?? "Up to 1Gbps", + description: + meta?.description ?? + "For mansions and apartment buildings. Speed depends on your building.", + iconType: meta?.iconType ?? "apartment", startingPrice, setupFee, tiers, @@ -323,10 +255,10 @@ export function PublicInternetPlansContent({ }); } - // Sort by order + // Sort by display order return offerings.sort((a, b) => { - const orderA = offeringMeta[a.offeringType]?.order ?? 99; - const orderB = offeringMeta[b.offeringType]?.order ?? 99; + const orderA = offeringDisplayOrder[a.offeringType] ?? 99; + const orderB = offeringDisplayOrder[b.offeringType] ?? 99; return orderA - orderB; }); }, [servicesCatalog]); @@ -378,7 +310,7 @@ export function PublicInternetPlansContent({ className="animate-in fade-in slide-in-from-bottom-8 duration-700" style={{ animationDelay: "300ms" }} > - + {/* Connection types section */} @@ -450,34 +382,10 @@ export function PublicInternetPlansContent({ * Clean, polished design optimized for conversion */ export function PublicInternetPlansView() { - return ; -} - -// Helper functions -function getSpeedBadge(offeringType: string): string { - const speeds: Record = { - "Apartment 100M": "100Mbps", - "Apartment 1G": "1Gbps", - "Home 1G": "1Gbps", - "Home 10G": "10Gbps", - }; - return speeds[offeringType] ?? "1Gbps"; -} - -function getTierDescription(tier: string): string { - const descriptions: Record = { - Silver: "Use your own router. Best for tech-savvy users.", - Gold: "Includes WiFi router rental. Our most popular choice.", - Platinum: "Premium equipment with mesh WiFi for larger homes.", - }; - return descriptions[tier] ?? ""; -} - -function getTierFeatures(tier: string): string[] { - const features: Record = { - Silver: ["NTT modem + ISP connection", "Use your own router", "Email/ticket support"], - Gold: ["NTT modem + ISP connection", "WiFi router included", "Priority phone support"], - Platinum: ["NTT modem + ISP connection", "Mesh WiFi system included", "Dedicated support line"], - }; - return features[tier] ?? []; + return ( + + ); } diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx index e5fa4b91..e2a9b5b2 100644 --- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx +++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx @@ -1,72 +1,19 @@ "use client"; -import { - ShieldCheck, - Zap, - Wifi, - Router, - Globe, - Headphones, - Package, - CreditCard, - Play, - MonitorPlay, -} from "lucide-react"; +import { ShieldCheck, Zap, CreditCard, Play, Globe, Package } from "lucide-react"; import { usePublicVpnCatalog } from "@/features/services/hooks"; +import { VPN_FEATURES } from "@/features/services/utils"; import { LoadingCard } from "@/components/atoms"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; -import { - ServiceHighlights, - type HighlightFeature, -} from "@/features/services/components/base/ServiceHighlights"; +import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights"; import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks"; import { ServiceCTA } from "@/features/services/components/base/ServiceCTA"; import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ"; -// VPN-specific features for ServiceHighlights -const vpnFeatures: HighlightFeature[] = [ - { - icon: , - title: "Pre-configured Router", - description: "Ready to use out of the box — just plug in and connect", - highlight: "Plug & play", - }, - { - icon: , - title: "US & UK Servers", - description: "Access content from San Francisco or London regions", - highlight: "2 locations", - }, - { - icon: , - title: "Streaming Ready", - description: "Works with Apple TV, Roku, Amazon Fire, and more", - highlight: "All devices", - }, - { - icon: , - title: "Separate Network", - description: "VPN runs on dedicated WiFi, keep regular internet normal", - highlight: "No interference", - }, - { - icon: , - title: "Router Rental Included", - description: "No equipment purchase — router rental is part of the plan", - highlight: "No hidden costs", - }, - { - icon: , - title: "English Support", - description: "Full English assistance for setup and troubleshooting", - highlight: "Dedicated help", - }, -]; - // Steps for HowItWorks const vpnSteps: HowItWorksStep[] = [ { @@ -207,7 +154,7 @@ export function PublicVpnPlansView() { className="animate-in fade-in slide-in-from-bottom-8 duration-700" style={{ animationDelay: "400ms" }} > - + {/* Plans Section */} diff --git a/docs/features/unified-get-started-flow.md b/docs/features/unified-get-started-flow.md new file mode 100644 index 00000000..b3fc2aed --- /dev/null +++ b/docs/features/unified-get-started-flow.md @@ -0,0 +1,181 @@ +# Unified "Get Started" Flow + +## Overview + +The unified get-started flow provides a single entry point for customer account creation and eligibility checking. It replaces separate `/signup` and `/migrate` pages with a streamlined flow at `/auth/get-started`. + +## User Flows + +### 1. Direct Account Creation (`/auth/get-started`) + +``` +/auth/get-started + │ + ├─→ Step 1: Enter email + │ + ├─→ Step 2: Verify email (6-digit OTP) + │ + └─→ Step 3: System checks accounts & routes: + │ + ├─→ Portal exists → "Go to login" + ├─→ WHMCS exists (unmapped) → "Link account" (enter WHMCS password) + ├─→ SF exists (unmapped) → "Complete account" (pre-filled form) + └─→ Nothing exists → "Create account" (full form) +``` + +### 2. Eligibility Check First (`/services/internet/check-availability`) + +For customers who want to check internet availability before creating an account: + +``` +/services/internet (public) + │ + └─→ Click "Check Availability" + │ + └─→ /services/internet/check-availability (dedicated page) + │ + ├─→ Step 1: Enter name, email, address + │ + ├─→ Step 2: Verify email (6-digit OTP) + │ + └─→ Step 3: SF Account + Case created immediately + │ + ├─→ "Create Account Now" → Redirect to /auth/get-started + │ (email pre-verified, goes to complete-account) + │ + └─→ "Maybe Later" → Return to /services/internet + (SF Account created for agent to review) +``` + +## Account Status Routing + +| Portal | WHMCS | Salesforce | Mapping | → Result | +| ------ | ----- | ---------- | ------- | ------------------------------ | +| ✓ | ✓ | ✓ | ✓ | Go to login | +| ✓ | ✓ | - | ✓ | Go to login | +| - | ✓ | ✓ | - | Link WHMCS account (migrate) | +| - | ✓ | - | - | Link WHMCS account (migrate) | +| - | - | ✓ | - | Complete account (pre-filled) | +| - | - | - | - | Create new account (full form) | + +## Frontend Structure + +``` +apps/portal/src/features/get-started/ +├── api/get-started.api.ts # API client functions +├── stores/get-started.store.ts # Zustand state management +├── components/ +│ ├── GetStartedForm/ +│ │ ├── GetStartedForm.tsx # Main form container +│ │ └── steps/ +│ │ ├── EmailStep.tsx # Email input +│ │ ├── VerificationStep.tsx # OTP verification +│ │ ├── AccountStatusStep.tsx# Route based on status +│ │ ├── CompleteAccountStep.tsx # Full signup form +│ │ └── SuccessStep.tsx # Success confirmation +│ └── OtpInput/OtpInput.tsx # 6-digit code input +├── views/GetStartedView.tsx # Page view +└── index.ts # Public exports +``` + +## Eligibility Check Page + +**Location:** `apps/portal/src/features/services/views/PublicEligibilityCheck.tsx` +**Route:** `/services/internet/check-availability` + +A dedicated page for guests to check internet availability. This approach provides: + +- Better mobile experience with proper form spacing +- Clear user journey with bookmarkable URLs +- Natural browser navigation (back button works) +- Focused multi-step experience + +**Flow:** + +1. Collects name, email, and address (with Japan ZIP code lookup) +2. Verifies email with 6-digit OTP +3. Creates SF Account + Eligibility Case immediately on verification +4. Shows success with options: "Create Account Now" or "Maybe Later" + +## Backend Endpoints + +| Endpoint | Rate Limit | Purpose | +| ----------------------------------------- | ---------- | --------------------------------------------- | +| `POST /auth/get-started/send-code` | 5/5min | Send OTP to email | +| `POST /auth/get-started/verify-code` | 10/5min | Verify OTP, return account status | +| `POST /auth/get-started/quick-check` | 5/15min | Guest eligibility (creates SF Account + Case) | +| `POST /auth/get-started/complete-account` | 5/15min | Complete SF-only account | +| `POST /auth/get-started/maybe-later` | 3/10min | Create SF + Case (legacy) | + +## Domain Schemas + +Location: `packages/domain/get-started/` + +Key schemas: + +- `sendVerificationCodeRequestSchema` - email only +- `verifyCodeRequestSchema` - email + 6-digit code +- `quickEligibilityRequestSchema` - sessionToken, name, address +- `completeAccountRequestSchema` - sessionToken + password + profile fields +- `accountStatusSchema` - `portal_exists | whmcs_unmapped | sf_unmapped | new_customer` + +## OTP Security + +- **Storage**: Redis with key format `otp:{email}` +- **TTL**: 10 minutes +- **Max Attempts**: 3 per code +- **Rate Limits**: 5 codes per 5 minutes + +## Handoff from Eligibility Check + +When a user clicks "Create Account Now" from the eligibility check page: + +1. Email stored in sessionStorage: `get-started-email` +2. Verification flag stored: `get-started-verified=true` +3. Redirect to: `/auth/get-started?email={email}&verified=true` +4. GetStartedView detects handoff and: + - Sets account status to `sf_unmapped` (SF Account was created during eligibility) + - Skips to `complete-account` step + - User only needs to add password + profile details + +When a user clicks "Maybe Later": + +- Returns to `/services/internet` plans page +- SF Account already created during eligibility check (agent can review) +- User can return anytime and use the same email to continue + +## Testing Checklist + +### Manual Testing + +1. **New customer flow**: Enter new email → Verify OTP → Full signup form +2. **SF-only flow**: Enter email with SF account → Verify → Pre-filled form, just add password +3. **WHMCS migration**: Enter email with WHMCS → Verify → Enter WHMCS password +4. **Eligibility check**: Click "Check Availability" on plans page → Dedicated page → Enter details → OTP → "Create Account" or "Maybe Later" +5. **Return flow**: Customer returns, enters same email → Auto-links to SF account +6. **Mobile experience**: Test eligibility check page on mobile viewport + +### Security Testing + +- Verify OTP expires after 10 minutes +- Verify max 3 attempts per code +- Verify rate limits on all endpoints +- Verify email enumeration is prevented (same response until OTP verified) + +## Routes + +| Route | Action | +| --------------------------------------- | -------------------------------- | +| `/auth/get-started` | Unified account creation page | +| `/auth/signup` | Redirects to `/auth/get-started` | +| `/auth/migrate` | Redirects to `/auth/get-started` | +| `/services/internet/check-availability` | Guest eligibility check (public) | + +## Environment Variables + +```bash +# OTP Configuration +OTP_TTL_SECONDS=600 # 10 minutes +OTP_MAX_ATTEMPTS=3 +GET_STARTED_SESSION_TTL=3600 # 1 hour +``` diff --git a/docs/integrations/japanpost/api-reference.md b/docs/integrations/japanpost/api-reference.md new file mode 100644 index 00000000..7681fd99 --- /dev/null +++ b/docs/integrations/japanpost/api-reference.md @@ -0,0 +1,231 @@ +# Japan Post Digital Address API Reference + +**Version:** 1.0.1.250707 + +The Digital Address API provides features such as token issuance, ZIP Code search, company-specific ZIP Code search, and Digital Address lookup. + +> ZIP Code refers to Postal Code + +--- + +## Authentication + +### Obtaining the API Usage Token (1-1028) + +Based on the `grant_type` of `client_credentials` in OAuth2.0, the API returns a usage token in response to the token request. + +**Endpoint:** `POST /api/v1/j/token` + +#### Header Parameters + +| Parameter | Required | Description | +| ----------------- | -------- | ----------------- | +| `x-forwarded-for` | Yes | Source IP address | + +#### Request Body + +```json +{ + "grant_type": "client_credentials", + "client_id": "TEST7t6fj7eqC5v6UDaHlpvvtesttest", + "secret_key": "testGzhSdzpZ1muyICtest0123456789" +} +``` + +| Field | Required | Description | +| ------------ | -------- | ------------------------------- | +| `grant_type` | Yes | Fixed as `"client_credentials"` | +| `client_id` | Yes | Client ID | +| `secret_key` | Yes | Secret Key | + +#### Response (200 OK) + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "read" +} +``` + +| Field | Type | Description | +| ------------ | ------- | --------------------------- | +| `token` | string | Access Token (JWT format) | +| `token_type` | string | Token Type | +| `expires_in` | integer | Validity duration (seconds) | +| `scope` | string | Scope | + +#### Error Responses + +| Status | Description | +| ------ | --------------------------------------------- | +| 400 | Bad Request - Invalid client_id or secret_key | +| 401 | Unauthorized | +| 403 | Forbidden | +| 500 | Internal Server Error | + +**Error Response Format:** + +```json +{ + "request_id": "(API execution tracking ID)", + "error_code": "400-1028-0001", + "message": "クライアントIDまたはシークレットキーが違います" +} +``` + +--- + +## Endpoints + +### Code Number Search (1-1029) + +Unified search endpoint for ZIP Code, Unique Corporate ZIP Code, and Digital Address. + +**Endpoint:** `GET /api/v1/searchcode/{search_code}` + +**Authorization:** Bearer token + +#### Path Parameters + +| Parameter | Required | Description | +| ------------- | -------- | ---------------------------------------------------------------------- | +| `search_code` | Yes | ZIP Code (min 3 digits), Unique Corporate ZIP Code, or Digital Address | + +#### Query Parameters + +| Parameter | Default | Description | +| ------------ | ------- | -------------------------------------------------------------------------------------------- | +| `page` | 1 | Page number | +| `limit` | 1000 | Maximum records to retrieve (max: 1000) | +| `ec_uid` | - | Provider's user ID | +| `choikitype` | 1 | Town field format: `1` = without brackets, `2` = with brackets | +| `searchtype` | 1 | Search target: `1` = ZIP + Corporate ZIP + Digital Address, `2` = ZIP + Digital Address only | + +#### Response (200 OK) + +```json +{ + "addresses": [{}, {}], + "searchtype": "bizzipcode", + "limit": 10, + "count": 1, + "page": 1 +} +``` + +#### Error Responses + +| Status | Description | +| ------ | --------------------- | +| 400 | Bad Request | +| 401 | Unauthorized | +| 404 | Not Found | +| 500 | Internal Server Error | + +**Error Response Format:** + +```json +{ + "request_id": "string", + "error_code": "string", + "message": "string" +} +``` + +--- + +### ZIP Code Search by Address (1-1018/1-1019) + +Search for the corresponding ZIP Code and address information from part of the address. + +**Endpoint:** `POST /api/v1/addresszip` + +**Authorization:** Bearer token + +#### Request Body + +```json +{ + "pref_code": "13", + "city_code": "13102", + "flg_getcity": 0, + "flg_getpref": 0, + "page": 1, + "limit": 100 +} +``` + +| Field | Description | +| ------------- | ---------------------------------------------------------- | +| `pref_code` | Prefecture Code | +| `pref_name` | Prefecture Name | +| `pref_kana` | Prefecture Name (Kana) | +| `pref_roma` | Prefecture Name (Romanized) | +| `city_code` | City Code | +| `city_name` | City Name | +| `city_kana` | City Name (Kana) | +| `city_roma` | City Name (Romanized) | +| `town_name` | Town | +| `town_kana` | Town (Kana) | +| `town_roma` | Town (Romanized) | +| `freeword` | Free Word search | +| `flg_getcity` | Flag for city list only (0: all, 1: city only) | +| `flg_getpref` | Flag for prefecture list only (0: all, 1: prefecture only) | +| `page` | Page Number (default: 1) | +| `limit` | Max records (default: 1000, max: 1000) | + +**Priority Notes:** + +- If both `pref_code` and `pref_name` are provided, `pref_code` takes priority +- If both `city_code` and `city_name` are provided, `city_code` takes priority + +#### Response (200 OK) + +```json +{ + "addresses": [[], []], + "level": 2, + "limit": 100, + "count": 2, + "page": 1 +} +``` + +**Matching Levels:** + +- Level 1: Matches at prefecture level +- Level 2: Matches at city/ward/town/village level +- Level 3: Matches at town area level + +--- + +## Environment Variables + +| Variable | Required | Description | +| -------------------------- | -------- | ------------------------------------------- | +| `JAPAN_POST_API_URL` | Yes | Base URL for Japan Post Digital Address API | +| `JAPAN_POST_CLIENT_ID` | Yes | OAuth client ID | +| `JAPAN_POST_CLIENT_SECRET` | Yes | OAuth client secret | +| `JAPAN_POST_TIMEOUT` | No | Request timeout in ms (default: 10000) | + +--- + +## Error Codes Reference + +| Error Code Pattern | HTTP Status | Description | +| ------------------ | ----------- | ------------------------------- | +| `400-1028-0001` | 400 | Invalid client_id or secret_key | +| `401-*` | 401 | Token invalid or expired | +| `404-*` | 404 | Resource not found | +| `500-*` | 500 | Internal server error | + +--- + +## Notes + +- ZIP codes must be 7 digits (hyphens are stripped automatically) +- If fewer than 7 digits are provided, the API searches for codes starting with the entered value +- Token is JWT format and should be cached until near expiration +- Rate limiting may apply - implement exponential backoff for retries diff --git a/packages/domain/auth/contract.ts b/packages/domain/auth/contract.ts index cd168dda..afc1f45f 100644 --- a/packages/domain/auth/contract.ts +++ b/packages/domain/auth/contract.ts @@ -47,6 +47,34 @@ export const GENDER = { export type GenderValue = (typeof GENDER)[keyof typeof GENDER]; +// ============================================================================ +// Security Configuration Constants +// ============================================================================ + +/** + * Password reset token configuration + * Single-use tokens tracked in Redis + */ +export const PASSWORD_RESET_CONFIG = { + /** Time-to-live in seconds (15 minutes) */ + TTL_SECONDS: 900, + /** Tokens can only be used once */ + SINGLE_USE: true, +} as const; + +/** + * OTP (One-Time Password) configuration + * Used for email verification in get-started flow + */ +export const OTP_CONFIG = { + /** Time-to-live in seconds (10 minutes) */ + TTL_SECONDS: 600, + /** Maximum verification attempts before invalidation */ + MAX_ATTEMPTS: 3, + /** Length of generated code */ + CODE_LENGTH: 6, +} as const; + // ============================================================================ // Re-export Types from Schema (Schema-First Approach) // ============================================================================ @@ -69,6 +97,7 @@ export type { // Token types AuthTokens, AuthSession, + PasswordResetTokenPayload, // Response types AuthResponse, SignupResult, diff --git a/packages/domain/auth/index.ts b/packages/domain/auth/index.ts index 8c95ac07..9dd040e7 100644 --- a/packages/domain/auth/index.ts +++ b/packages/domain/auth/index.ts @@ -17,6 +17,8 @@ export { AUTH_ERROR_CODE, TOKEN_TYPE, GENDER, + PASSWORD_RESET_CONFIG, + OTP_CONFIG, type AuthErrorCode, type TokenTypeValue, type GenderValue, @@ -40,6 +42,7 @@ export type { // Token types AuthTokens, AuthSession, + PasswordResetTokenPayload, // Response types AuthResponse, SignupResult, @@ -77,6 +80,7 @@ export { // Token schemas authTokensSchema, authSessionSchema, + passwordResetTokenPayloadSchema, // Response schemas authResponseSchema, diff --git a/packages/domain/auth/schema.ts b/packages/domain/auth/schema.ts index 9c31f90a..9cfe2a9e 100644 --- a/packages/domain/auth/schema.ts +++ b/packages/domain/auth/schema.ts @@ -137,6 +137,21 @@ export const refreshTokenRequestSchema = z.object({ // Token Schemas // ============================================================================ +/** + * Password reset token payload schema + * Used for validating JWT payload structure in password reset tokens + */ +export const passwordResetTokenPayloadSchema = z.object({ + /** User ID (standard JWT subject) */ + sub: z.string().uuid(), + /** Unique token identifier for single-use tracking */ + tokenId: z.string().uuid(), + /** Purpose claim to distinguish from other token types */ + purpose: z.literal("password_reset"), +}); + +export type PasswordResetTokenPayload = z.infer; + export const authTokensSchema = z.object({ accessToken: z.string().min(1, "Access token is required"), refreshToken: z.string().min(1, "Refresh token is required"), diff --git a/packages/domain/get-started/contract.ts b/packages/domain/get-started/contract.ts new file mode 100644 index 00000000..8e44cbae --- /dev/null +++ b/packages/domain/get-started/contract.ts @@ -0,0 +1,112 @@ +/** + * Get Started Domain - Contract + * + * Types and constants for the unified "Get Started" flow that handles: + * - Email verification (OTP) + * - Account status detection + * - Quick eligibility check + * - Account completion for SF-only accounts + */ + +import type { z } from "zod"; + +import type { + sendVerificationCodeRequestSchema, + sendVerificationCodeResponseSchema, + verifyCodeRequestSchema, + verifyCodeResponseSchema, + quickEligibilityRequestSchema, + quickEligibilityResponseSchema, + completeAccountRequestSchema, + maybeLaterRequestSchema, + maybeLaterResponseSchema, + getStartedSessionSchema, +} from "./schema.js"; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Account status after email verification + * Determines which flow the user should follow + */ +export const ACCOUNT_STATUS = { + /** User has a portal account - redirect to login */ + PORTAL_EXISTS: "portal_exists", + /** User has WHMCS account but no portal - migrate flow */ + WHMCS_UNMAPPED: "whmcs_unmapped", + /** User has SF account but no WHMCS/portal - complete account flow */ + SF_UNMAPPED: "sf_unmapped", + /** No account exists - full signup flow */ + NEW_CUSTOMER: "new_customer", +} as const; + +export type AccountStatus = (typeof ACCOUNT_STATUS)[keyof typeof ACCOUNT_STATUS]; + +/** + * OTP verification error codes + */ +export const OTP_ERROR_CODE = { + /** Code has expired */ + EXPIRED: "otp_expired", + /** Invalid code entered */ + INVALID: "otp_invalid", + /** Too many failed attempts */ + MAX_ATTEMPTS: "otp_max_attempts", + /** Rate limit exceeded for sending codes */ + RATE_LIMITED: "otp_rate_limited", +} as const; + +export type OtpErrorCode = (typeof OTP_ERROR_CODE)[keyof typeof OTP_ERROR_CODE]; + +/** + * Get Started flow error codes + */ +export const GET_STARTED_ERROR_CODE = { + /** Session token is invalid or expired */ + INVALID_SESSION: "invalid_session", + /** Email not verified yet */ + EMAIL_NOT_VERIFIED: "email_not_verified", + /** SF account creation failed */ + SF_CREATION_FAILED: "sf_creation_failed", + /** WHMCS account creation failed */ + WHMCS_CREATION_FAILED: "whmcs_creation_failed", +} as const; + +export type GetStartedErrorCode = + (typeof GET_STARTED_ERROR_CODE)[keyof typeof GET_STARTED_ERROR_CODE]; + +// ============================================================================ +// Request Types +// ============================================================================ + +export type SendVerificationCodeRequest = z.infer; +export type VerifyCodeRequest = z.infer; +export type QuickEligibilityRequest = z.infer; +export type CompleteAccountRequest = z.infer; +export type MaybeLaterRequest = z.infer; + +// ============================================================================ +// Response Types +// ============================================================================ + +export type SendVerificationCodeResponse = z.infer; +export type VerifyCodeResponse = z.infer; +export type QuickEligibilityResponse = z.infer; +export type MaybeLaterResponse = z.infer; + +// ============================================================================ +// Session Types +// ============================================================================ + +export type GetStartedSession = z.infer; + +// ============================================================================ +// Error Types +// ============================================================================ + +export interface GetStartedError { + code: OtpErrorCode | GetStartedErrorCode; + message: string; +} diff --git a/packages/domain/get-started/index.ts b/packages/domain/get-started/index.ts new file mode 100644 index 00000000..2029e536 --- /dev/null +++ b/packages/domain/get-started/index.ts @@ -0,0 +1,58 @@ +/** + * Get Started Domain + * + * Unified "Get Started" flow for: + * - Email verification (OTP) + * - Account status detection + * - Quick eligibility check (guest) + * - Account completion (SF-only → full account) + * - "Maybe Later" flow + */ + +// ============================================================================ +// Constants & Contract Types +// ============================================================================ + +export { + ACCOUNT_STATUS, + OTP_ERROR_CODE, + GET_STARTED_ERROR_CODE, + type AccountStatus, + type OtpErrorCode, + type GetStartedErrorCode, + type SendVerificationCodeRequest, + type SendVerificationCodeResponse, + type VerifyCodeRequest, + type VerifyCodeResponse, + type QuickEligibilityRequest, + type QuickEligibilityResponse, + type CompleteAccountRequest, + type MaybeLaterRequest, + type MaybeLaterResponse, + type GetStartedSession, + type GetStartedError, +} from "./contract.js"; + +// ============================================================================ +// Schemas (for validation) +// ============================================================================ + +export { + // OTP schemas + sendVerificationCodeRequestSchema, + sendVerificationCodeResponseSchema, + otpCodeSchema, + verifyCodeRequestSchema, + verifyCodeResponseSchema, + accountStatusSchema, + // Quick eligibility schemas + quickEligibilityRequestSchema, + quickEligibilityResponseSchema, + // Account completion schemas + completeAccountRequestSchema, + // Maybe later schemas + maybeLaterRequestSchema, + maybeLaterResponseSchema, + // Session schema + getStartedSessionSchema, +} from "./schema.js"; diff --git a/packages/domain/get-started/schema.ts b/packages/domain/get-started/schema.ts new file mode 100644 index 00000000..94f643a1 --- /dev/null +++ b/packages/domain/get-started/schema.ts @@ -0,0 +1,231 @@ +/** + * Get Started Domain - Schemas + * + * Zod validation schemas for the unified "Get Started" flow. + */ + +import { z } from "zod"; + +import { + emailSchema, + nameSchema, + passwordSchema, + phoneSchema, + genderEnum, +} from "../common/schema.js"; +import { addressFormSchema } from "../customer/schema.js"; + +// ============================================================================ +// OTP Verification Schemas +// ============================================================================ + +/** + * Request to send a verification code to an email address + */ +export const sendVerificationCodeRequestSchema = z.object({ + email: emailSchema, +}); + +/** + * Response after sending verification code + */ +export const sendVerificationCodeResponseSchema = z.object({ + /** Whether the code was sent successfully */ + sent: z.boolean(), + /** Message to display to user */ + message: z.string(), + /** When a new code can be requested (ISO timestamp) */ + retryAfter: z.string().datetime().optional(), +}); + +/** + * 6-digit OTP code schema + */ +export const otpCodeSchema = z + .string() + .length(6, "Code must be 6 digits") + .regex(/^\d{6}$/, "Code must be 6 digits"); + +/** + * Request to verify an OTP code + */ +export const verifyCodeRequestSchema = z.object({ + email: emailSchema, + code: otpCodeSchema, +}); + +/** + * Account status enum for verification response + */ +export const accountStatusSchema = z.enum([ + "portal_exists", + "whmcs_unmapped", + "sf_unmapped", + "new_customer", +]); + +/** + * Response after verifying OTP code + * Includes account status to determine next flow + */ +export const verifyCodeResponseSchema = z.object({ + /** Whether the code was valid */ + verified: z.boolean(), + /** Error message if verification failed */ + error: z.string().optional(), + /** Remaining attempts if verification failed */ + attemptsRemaining: z.number().optional(), + /** Session token for continuing the flow (only if verified) */ + sessionToken: z.string().optional(), + /** Account status determining next flow (only if verified) */ + accountStatus: accountStatusSchema.optional(), + /** Pre-filled data from existing account (only if SF or WHMCS exists) */ + prefill: z + .object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + address: addressFormSchema.partial().optional(), + eligibilityStatus: z.string().optional(), + }) + .optional(), +}); + +// ============================================================================ +// Quick Eligibility Check Schemas +// ============================================================================ + +/** + * ISO date string (YYYY-MM-DD) + */ +const isoDateOnlySchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Enter a valid date (YYYY-MM-DD)") + .refine(value => !Number.isNaN(Date.parse(value)), "Enter a valid date (YYYY-MM-DD)"); + +/** + * Request for quick eligibility check (guest flow) + * Minimal data required to create SF Account and check eligibility + */ +export const quickEligibilityRequestSchema = z.object({ + /** Session token from email verification */ + sessionToken: z.string().min(1, "Session token is required"), + /** Customer first name */ + firstName: nameSchema, + /** Customer last name */ + lastName: nameSchema, + /** Full address for eligibility check */ + address: addressFormSchema, + /** Optional phone number */ + phone: phoneSchema.optional(), +}); + +/** + * Response from quick eligibility check + */ +export const quickEligibilityResponseSchema = z.object({ + /** Whether the request was submitted successfully */ + submitted: z.boolean(), + /** Case ID for the eligibility request */ + requestId: z.string().optional(), + /** SF Account ID created */ + sfAccountId: z.string().optional(), + /** Message to display */ + message: z.string(), +}); + +// ============================================================================ +// Account Completion Schemas +// ============================================================================ + +/** + * Request to complete account for SF-only users + * Creates WHMCS client and Portal user, links to existing SF Account + */ +export const completeAccountRequestSchema = z.object({ + /** Session token from verified email */ + sessionToken: z.string().min(1, "Session token is required"), + /** Password for the new portal account */ + password: passwordSchema, + /** Phone number (may be pre-filled from SF) */ + phone: phoneSchema, + /** Date of birth */ + dateOfBirth: isoDateOnlySchema, + /** Gender */ + gender: genderEnum, + /** Accept terms of service */ + acceptTerms: z.boolean().refine(val => val === true, { + message: "You must accept the terms of service", + }), + /** Marketing consent */ + marketingConsent: z.boolean().optional(), +}); + +// ============================================================================ +// "Maybe Later" Flow Schemas +// ============================================================================ + +/** + * Request for "Maybe Later" flow + * Creates SF Account and eligibility case, customer can return later + */ +export const maybeLaterRequestSchema = z.object({ + /** Session token from email verification */ + sessionToken: z.string().min(1, "Session token is required"), + /** Customer first name */ + firstName: nameSchema, + /** Customer last name */ + lastName: nameSchema, + /** Full address for eligibility check */ + address: addressFormSchema, + /** Optional phone number */ + phone: phoneSchema.optional(), +}); + +/** + * Response from "Maybe Later" flow + */ +export const maybeLaterResponseSchema = z.object({ + /** Whether the SF account and case were created */ + success: z.boolean(), + /** Case ID for the eligibility request */ + requestId: z.string().optional(), + /** Message to display */ + message: z.string(), +}); + +// ============================================================================ +// Session Schema +// ============================================================================ + +/** + * Get Started session stored in Redis + * Tracks progress through the unified flow + */ +export const getStartedSessionSchema = z.object({ + /** Email address (normalized) */ + email: z.string(), + /** Whether email has been verified via OTP */ + emailVerified: z.boolean(), + /** First name (if provided during quick check) */ + firstName: z.string().optional(), + /** Last name (if provided during quick check) */ + lastName: z.string().optional(), + /** Address (if provided during quick check) */ + address: addressFormSchema.partial().optional(), + /** Phone number (if provided) */ + phone: z.string().optional(), + /** Account status after verification */ + accountStatus: accountStatusSchema.optional(), + /** SF Account ID (if exists or created) */ + sfAccountId: z.string().optional(), + /** WHMCS Client ID (if exists) */ + whmcsClientId: z.number().optional(), + /** Eligibility status from SF */ + eligibilityStatus: z.string().optional(), + /** Session creation timestamp */ + createdAt: z.string().datetime(), + /** Session expiry timestamp */ + expiresAt: z.string().datetime(), +}); diff --git a/packages/domain/package.json b/packages/domain/package.json index 88e44815..7e0c706b 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -118,6 +118,10 @@ "./address/providers": { "import": "./dist/address/providers/index.js", "types": "./dist/address/providers/index.d.ts" + }, + "./get-started": { + "import": "./dist/get-started/index.js", + "types": "./dist/get-started/index.d.ts" } }, "scripts": { diff --git a/packages/domain/services/providers/salesforce/mapper.ts b/packages/domain/services/providers/salesforce/mapper.ts index 78c20c57..93212214 100644 --- a/packages/domain/services/providers/salesforce/mapper.ts +++ b/packages/domain/services/providers/salesforce/mapper.ts @@ -53,7 +53,6 @@ function baseProduct( id: product.Id, sku, name: product.Name ?? sku, - catalogMetadata: {}, }; if (product.Description) base.description = product.Description; @@ -124,7 +123,6 @@ export function mapInternetInstallation( return { ...base, catalogMetadata: { - ...base.catalogMetadata, installationTerm: inferInstallationTermFromSku(base.sku), }, }; @@ -143,7 +141,6 @@ export function mapInternetAddon( bundledAddonId, isBundledAddon, catalogMetadata: { - ...base.catalogMetadata, addonType: inferAddonTypeFromSku(base.sku), }, }; @@ -185,7 +182,6 @@ export function mapSimActivationFee( return { ...simProduct, catalogMetadata: { - ...(simProduct.catalogMetadata ?? {}), isDefault: false, // Will be handled by service fallback }, }; diff --git a/packages/domain/services/schema.ts b/packages/domain/services/schema.ts index 1eddd7a3..5736f6b6 100644 --- a/packages/domain/services/schema.ts +++ b/packages/domain/services/schema.ts @@ -11,6 +11,10 @@ import { addressSchema } from "../customer/index.js"; // Base Catalog Product Schema // ============================================================================ +/** + * Base catalog product schema without catalogMetadata. + * Each specific product schema should define its own typed catalogMetadata if needed. + */ export const catalogProductBaseSchema = z.object({ id: z.string(), sku: z.string(), @@ -21,7 +25,6 @@ export const catalogProductBaseSchema = z.object({ monthlyPrice: z.number().optional(), oneTimePrice: z.number().optional(), unitPrice: z.number().optional(), - catalogMetadata: z.record(z.string(), z.unknown()).optional(), }); // ============================================================================ @@ -74,6 +77,11 @@ export const internetInstallationCatalogItemSchema = internetCatalogProductSchem export const internetAddonCatalogItemSchema = internetCatalogProductSchema.extend({ isBundledAddon: z.boolean().optional(), bundledAddonId: z.string().optional(), + catalogMetadata: z + .object({ + addonType: z.string().optional(), + }) + .optional(), }); export const internetCatalogCollectionSchema = z.object({ diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index d8abf246..6a1a9081 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -18,6 +18,7 @@ "address/**/*", "auth/**/*", "billing/**/*", + "get-started/**/*", "services/**/*", "checkout/**/*", "common/**/*",