feat(address): integrate AddressModule and optimize Japan address form handling

This commit is contained in:
barsa 2026-01-14 13:54:01 +09:00
parent 78689da8fb
commit fd15324ef0
63 changed files with 5053 additions and 897 deletions

View File

@ -16,6 +16,7 @@ import { SupportModule } from "@bff/modules/support/support.module.js";
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js"; import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
import { VerificationModule } from "@bff/modules/verification/verification.module.js"; import { VerificationModule } from "@bff/modules/verification/verification.module.js";
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
import { AddressModule } from "@bff/modules/address/address.module.js";
export const apiRoutes: Routes = [ export const apiRoutes: Routes = [
{ {
@ -38,6 +39,7 @@ export const apiRoutes: Routes = [
{ path: "", module: RealtimeApiModule }, { path: "", module: RealtimeApiModule },
{ path: "", module: VerificationModule }, { path: "", module: VerificationModule },
{ path: "", module: NotificationsModule }, { path: "", module: NotificationsModule },
{ path: "", module: AddressModule },
], ],
}, },
]; ];

View File

@ -6,3 +6,4 @@ export {
type RateLimitOptions, type RateLimitOptions,
RATE_LIMIT_KEY, RATE_LIMIT_KEY,
} from "./rate-limit.decorator.js"; } from "./rate-limit.decorator.js";
export { getRequestFingerprint } from "./rate-limit.util.js";

View File

@ -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 | undefined>): string { export function buildRateLimitKey(...parts: Array<string | undefined>): string {
return parts.filter(Boolean).join(":"); return parts.filter(Boolean).join(":");
} }

View File

@ -38,38 +38,61 @@ export class JapanPostAddressService {
// Validate format // Validate format
if (!/^\d{7}$/.test(normalizedZip)) { 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)"); throw new BadRequestException("ZIP code must be 7 digits (e.g., 100-0001)");
} }
// Check if service is configured // Check if service is configured
if (!this.connection.isConfigured()) { 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"); throw new ServiceUnavailableException("Address lookup service is not available");
} }
const startTime = Date.now();
try { try {
const rawResponse = await this.connection.searchByZipCode(normalizedZip); const rawResponse = await this.connection.searchByZipCode(normalizedZip);
// Use domain mapper for transformation (single transformation point) // Use domain mapper for transformation (single transformation point)
const result = JapanPost.transformJapanPostSearchResponse(rawResponse); const result = JapanPost.transformJapanPostSearchResponse(rawResponse);
this.logger.log("Japan Post address lookup completed", { this.logger.log("Address lookup completed", {
zipCode: normalizedZip, zipCode: normalizedZip,
found: result.count > 0, found: result.count > 0,
count: result.count, count: result.count,
durationMs: Date.now() - startTime,
}); });
return result; return result;
} catch (error) { } 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) { if (error instanceof BadRequestException || error instanceof ServiceUnavailableException) {
throw error; throw error;
} }
this.logger.error("Japan Post address lookup failed", { // Check if this is an HTTP error from connection layer (already logged there)
zipCode: normalizedZip, const errorMessage = extractErrorMessage(error);
error: 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."); throw new ServiceUnavailableException("Failed to lookup address. Please try again.");
} }

View File

@ -33,6 +33,15 @@ interface ConfigValidationError {
message: string; message: string;
} }
/**
* Japan Post API error response format
*/
interface JapanPostErrorResponse {
request_id?: string;
error_code?: string;
message?: string;
}
@Injectable() @Injectable()
export class JapanPostConnectionService implements OnModuleInit { export class JapanPostConnectionService implements OnModuleInit {
private accessToken: string | null = null; private accessToken: string | null = null;
@ -142,9 +151,11 @@ export class JapanPostConnectionService implements OnModuleInit {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const startTime = Date.now();
const tokenUrl = `${this.config.baseUrl}/api/v1/j/token`;
try { try {
const response = await fetch(`${this.config.baseUrl}/api/v1/j/token`, { const response = await fetch(tokenUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -158,11 +169,24 @@ export class JapanPostConnectionService implements OnModuleInit {
signal: controller.signal, signal: controller.signal,
}); });
const durationMs = Date.now() - startTime;
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => ""); const errorBody = await response.text().catch(() => "");
throw new Error( const parsedError = this.parseErrorResponse(errorBody);
`Token request failed: HTTP ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
); 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; const data = (await response.json()) as JapanPostTokenResponse;
@ -175,19 +199,98 @@ export class JapanPostConnectionService implements OnModuleInit {
this.logger.debug("Japan Post token acquired", { this.logger.debug("Japan Post token acquired", {
expiresIn: validated.expires_in, expiresIn: validated.expires_in,
tokenType: validated.token_type, tokenType: validated.token_type,
durationMs,
}); });
return token; return token;
} catch (error) { } catch (error) {
this.logger.error("Failed to acquire Japan Post access token", { const durationMs = Date.now() - startTime;
error: extractErrorMessage(error), 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; throw error;
} finally { } finally {
clearTimeout(timeoutId); 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 * Search addresses by ZIP code
* *
@ -199,12 +302,12 @@ export class JapanPostConnectionService implements OnModuleInit {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); 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 { 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, { const response = await fetch(url, {
method: "GET", method: "GET",
headers: { headers: {
@ -214,26 +317,59 @@ export class JapanPostConnectionService implements OnModuleInit {
signal: controller.signal, signal: controller.signal,
}); });
const durationMs = Date.now() - startTime;
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => ""); const errorBody = await response.text().catch(() => "");
throw new Error( const parsedError = this.parseErrorResponse(errorBody);
`ZIP code search failed: HTTP ${response.status}${errorText ? ` - ${errorText}` : ""}`
); 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(); const data = await response.json();
this.logger.debug("Japan Post search response received", { this.logger.debug("Japan Post ZIP search completed", {
zipCode, zipCode,
resultCount: (data as { count?: number }).count, resultCount: (data as { count?: number }).count,
durationMs,
}); });
return data; return data;
} catch (error) { } catch (error) {
this.logger.error("Japan Post ZIP code search failed", { const durationMs = Date.now() - startTime;
zipCode, const isAborted = error instanceof Error && error.name === "AbortError";
error: extractErrorMessage(error),
}); 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; throw error;
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);

View File

@ -3,6 +3,7 @@ import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers"; import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.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 * Service for handling and normalizing WHMCS API errors
@ -27,8 +28,10 @@ export class WhmcsErrorHandlerService {
/** /**
* Handle general request errors (network, timeout, etc.) * 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)) { if (this.isTimeoutError(error)) {
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
} }
@ -37,9 +40,25 @@ export class WhmcsErrorHandlerService {
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY); throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
} }
// If upstream already threw a DomainHttpException or HttpException with code, if (this.isRateLimitError(error)) {
// let the global exception filter handle it. throw new DomainHttpException(
throw error; 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( 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 * Get user-friendly error message for client consumption
*/ */
getUserFriendlyMessage(error: unknown): string { getUserFriendlyMessage(error: unknown): string {
const message = extractErrorMessage(error).toLowerCase(); const message = extractErrorMessage(error);
if (message.includes("timeout")) { // Use shared error pattern matcher with billing category
return "The request timed out. Please try again."; const commonResult = matchCommonError(message, "billing");
if (commonResult.matched) {
return commonResult.message;
} }
if (message.includes("network") || message.includes("connection")) { return "Billing operation failed. Please try again or contact support.";
return "Network error. Please check your connection and try again.";
}
return "An unexpected error occurred. Please try again later.";
} }
} }

View File

@ -11,6 +11,7 @@ import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js"; import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
import { TokenStorageService } from "./infra/token/token-storage.service.js"; import { TokenStorageService } from "./infra/token/token-storage.service.js";
import { TokenRevocationService } from "./infra/token/token-revocation.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 { EmailModule } from "@bff/infra/email/email.module.js";
import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js";
import { AuthTokenService } from "./infra/token/token.service.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 { SignupValidationService } from "./infra/workflows/signup/signup-validation.service.js";
import { SignupWhmcsService } from "./infra/workflows/signup/signup-whmcs.service.js"; import { SignupWhmcsService } from "./infra/workflows/signup/signup-whmcs.service.js";
import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-creation.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({ @Module({
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule], imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
controllers: [AuthController], controllers: [AuthController, GetStartedController],
providers: [ providers: [
// Application services // Application services
AuthFacade, AuthFacade,
@ -40,6 +46,7 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-
TokenRevocationService, TokenRevocationService,
AuthTokenService, AuthTokenService,
JoseJwtService, JoseJwtService,
PasswordResetTokenService,
// Signup workflow services // Signup workflow services
SignupWorkflowService, SignupWorkflowService,
SignupAccountResolverService, SignupAccountResolverService,
@ -49,6 +56,10 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-
// Other workflow services // Other workflow services
PasswordWorkflowService, PasswordWorkflowService,
WhmcsLinkWorkflowService, WhmcsLinkWorkflowService,
// Get Started flow services
OtpService,
GetStartedSessionService,
GetStartedWorkflowService,
// Guards and interceptors // Guards and interceptors
FailedLoginThrottleGuard, FailedLoginThrottleGuard,
AuthRateLimitService, AuthRateLimitService,

View File

@ -5,6 +5,12 @@ import type { UserAuth } from "@customer-portal/domain/customer";
export type RequestWithUser = Request & { user: User }; 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. * Internal auth result used inside the BFF to set cookies.
* Token strings must not be returned to the browser. * Token strings must not be returned to the browser.

View File

@ -11,3 +11,18 @@ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
*/ */
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession"; export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true); 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);

View File

@ -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<GetStartedSession, "expiresAt"> {
/** 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<number>("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<string> {
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<GetStartedSession | null> {
const sessionData = await this.cache.get<SessionData>(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<boolean> {
const sessionData = await this.cache.get<SessionData>(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<boolean> {
const sessionData = await this.cache.get<SessionData>(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<void> {
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<GetStartedSession | null> {
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));
}
}

View File

@ -0,0 +1,2 @@
export { OtpService, type OtpVerifyResult } from "./otp.service.js";
export { GetStartedSessionService } from "./get-started-session.service.js";

View File

@ -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<number>("OTP_TTL_SECONDS", 600); // 10 minutes
this.maxAttempts = this.config.get<number>("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<string> {
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<OtpVerifyResult> {
const normalizedEmail = this.normalizeEmail(email);
const key = this.buildKey(normalizedEmail);
const otpData = await this.cache.get<OtpData>(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<boolean> {
const normalizedEmail = this.normalizeEmail(email);
const key = this.buildKey(normalizedEmail);
const otpData = await this.cache.get<OtpData>(key);
return otpData !== null;
}
/**
* Delete any existing OTP for the given email
*/
async invalidate(email: string): Promise<void> {
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();
}
}

View File

@ -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<SendVerificationCodeResponse> {
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<VerifyCodeResponse> {
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<QuickEligibilityResponse> {
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<MaybeLaterResponse> {
// 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<AuthResultInternal> {
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<void> {
const templateId = this.config.get<string>("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: `
<p>Your verification code is: <strong>${code}</strong></p>
<p>This code expires in 10 minutes.</p>
<p>If you didn't request this code, please ignore this email.</p>
`,
});
}
}
private async sendMaybeLaterConfirmationEmail(
email: string,
firstName: string,
requestId: string
): Promise<void> {
const appBase = this.config.get<string>("APP_BASE_URL", "http://localhost:3000");
const templateId = this.config.get<string>("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: `
<p>Hi ${firstName},</p>
<p>We received your request to check internet availability.</p>
<p>We'll review this and email you the results within 1-2 business days.</p>
<p>When ready, create your account at: <a href="${appBase}/get-started">${appBase}/get-started</a></p>
<p>Just enter your email (${email}) to continue.</p>
`,
});
}
}
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<string> {
// 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<void> {
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"
);
}
}
}

View File

@ -15,7 +15,7 @@ import { EmailService } from "@bff/infra/email/email.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { AuthTokenService } from "../token/token.service.js"; import { AuthTokenService } from "../token/token.service.js";
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.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 { import {
type ChangePasswordRequest, type ChangePasswordRequest,
changePasswordRequestSchema, changePasswordRequestSchema,
@ -31,7 +31,7 @@ export class PasswordWorkflowService {
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly emailService: EmailService, private readonly emailService: EmailService,
private readonly jwtService: JoseJwtService, private readonly passwordResetTokenService: PasswordResetTokenService,
private readonly tokenService: AuthTokenService, private readonly tokenService: AuthTokenService,
private readonly authRateLimitService: AuthRateLimitService, private readonly authRateLimitService: AuthRateLimitService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
@ -113,10 +113,12 @@ export class PasswordWorkflowService {
} }
const user = await this.usersFacade.findByEmailInternal(email); const user = await this.usersFacade.findByEmailInternal(email);
if (!user) { if (!user) {
// Don't reveal whether user exists
return; 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<string>("APP_BASE_URL", "http://localhost:3000"); const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`; const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
@ -143,14 +145,13 @@ export class PasswordWorkflowService {
} }
async resetPassword(token: string, newPassword: string): Promise<void> { async resetPassword(token: string, newPassword: string): Promise<void> {
// 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( return withErrorHandling(
async () => { async () => {
const payload = await this.jwtService.verify<{ sub: string; purpose: string }>(token); const prismaUser = await this.usersFacade.findByIdInternal(userId);
if (payload.purpose !== "password_reset") {
throw new BadRequestException("Invalid token");
}
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
if (!prismaUser) throw new BadRequestException("Invalid token"); if (!prismaUser) throw new BadRequestException("Invalid token");
const passwordHash = await argon2.hash(newPassword); const passwordHash = await argon2.hash(newPassword);
@ -166,7 +167,7 @@ export class PasswordWorkflowService {
this.logger, this.logger,
{ {
context: "Reset password", context: "Reset password",
fallbackMessage: "Invalid or expired token", fallbackMessage: "Failed to reset password",
rethrow: [BadRequestException], rethrow: [BadRequestException],
} }
); );

View File

@ -223,9 +223,11 @@ export class SignupValidationService {
try { try {
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail); const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
if (existingSf) { 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( 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; return result;
} }

View File

@ -24,7 +24,14 @@ export interface SignupAccountCacheEntry {
*/ */
export interface SignupPreflightResult { export interface SignupPreflightResult {
canProceed: boolean; 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[]; messages: string[];
normalized: { normalized: {
email: string; email: string;

View File

@ -18,7 +18,7 @@ import {
type RequestWithRateLimit, type RequestWithRateLimit,
} from "./guards/failed-login-throttle.guard.js"; } from "./guards/failed-login-throttle.guard.js";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.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 { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.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) }; 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") @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 }; return { isAuthenticated: true, user: req.user };
} }

View File

@ -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<SendVerificationCodeResponseDto> {
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<VerifyCodeResponseDto> {
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<QuickEligibilityResponseDto> {
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<MaybeLaterResponseDto> {
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,
},
};
}
}

View File

@ -5,7 +5,11 @@ import { Reflector } from "@nestjs/core";
import type { Request } from "express"; import type { Request } from "express";
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js"; 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 { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js"; import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
@ -70,6 +74,33 @@ export class GlobalAuthGuard implements CanActivate {
return true; 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<boolean>(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 { try {
const token = extractAccessTokenFromRequest(request); const token = extractAccessTokenFromRequest(request);
if (!token) { if (!token) {

View File

@ -16,8 +16,9 @@ import { addressSchema, userSchema } from "@customer-portal/domain/customer";
import { bilingualAddressSchema } from "@customer-portal/domain/address"; import { bilingualAddressSchema } from "@customer-portal/domain/address";
import type { Address } from "@customer-portal/domain/customer"; import type { Address } from "@customer-portal/domain/customer";
import type { User } 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 { 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 UpdateAddressDto extends createZodDto(addressSchema.partial()) {}
class UpdateBilingualAddressDto extends createZodDto(bilingualAddressSchema) {} class UpdateBilingualAddressDto extends createZodDto(bilingualAddressSchema) {}
@ -34,12 +35,17 @@ export class UsersController {
/** /**
* GET /me - Get complete customer profile (includes address) * GET /me - Get complete customer profile (includes address)
* Profile data fetched from WHMCS (single source of truth) * 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) @UseGuards(SalesforceReadThrottleGuard)
@Get() @Get()
@ZodResponse({ description: "Get user profile", type: UserDto }) @ZodSerializerDto(userSchema.nullable())
async getProfile(@Req() req: RequestWithUser): Promise<User> { async getProfile(@Req() req: RequestWithOptionalUser): Promise<User | null> {
// This endpoint represents the authenticated user; treat missing user as an error. if (!req.user) {
return null;
}
return this.usersFacade.getProfile(req.user.id); return this.usersFacade.getProfile(req.user.id);
} }

View File

@ -0,0 +1,5 @@
import { GetStartedView } from "@/features/get-started";
export default function GetStartedPage() {
return <GetStartedView />;
}

View File

@ -1,5 +1,5 @@
import MigrateAccountView from "@/features/auth/views/MigrateAccountView"; import { redirect } from "next/navigation";
export default function MigrateAccountPage() { export default function MigrateAccountPage() {
return <MigrateAccountView />; redirect("/auth/get-started");
} }

View File

@ -1,5 +1,5 @@
import SignupView from "@/features/auth/views/SignupView"; import { redirect } from "next/navigation";
export default function SignupPage() { export default function SignupPage() {
return <SignupView />; redirect("/auth/get-started");
} }

View File

@ -17,7 +17,7 @@
* Japanese fields are stored separately for Salesforce sync. * 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 { JapanAddressForm, type JapanAddressFormData } from "./JapanAddressForm";
import { import {
type BilingualAddress, type BilingualAddress,
@ -106,8 +106,14 @@ function fromLegacyFormat(address: LegacyAddressData): Partial<JapanAddressFormD
} }
} }
// Determine residence type based on whether we have a room number // Only set residence type if there's existing address data
const residenceType = roomNumber ? RESIDENCE_TYPE.APARTMENT : RESIDENCE_TYPE.HOUSE; // For new users, leave it undefined so they must explicitly choose
const hasExistingAddress = address.postcode || address.state || address.city;
const residenceType = hasExistingAddress
? roomNumber
? RESIDENCE_TYPE.APARTMENT
: RESIDENCE_TYPE.HOUSE
: undefined;
return { return {
postcode: address.postcode || "", postcode: address.postcode || "",
@ -132,20 +138,26 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
const { values, errors, touched, setValue, setTouchedField } = form; const { values, errors, touched, setValue, setTouchedField } = form;
const address = values.address; const address = values.address;
// Track Japan address form data separately // Track if this is the first render to avoid infinite loops
const [japanData, setJapanData] = useState<JapanAddressFormData>(() => ({ const isInitialMount = useRef(true);
postcode: address.postcode || "",
prefecture: address.state || "", // Compute initial values only once on mount
city: address.city || "", const initialValues = useMemo(() => {
town: address.address2 || "", return {
buildingName: "", postcode: address.postcode || "",
roomNumber: "", prefecture: address.state || "",
residenceType: RESIDENCE_TYPE.APARTMENT, city: address.city || "",
prefectureJa: "", town: address.address2 || "",
cityJa: "", buildingName: "",
townJa: "", roomNumber: "",
...fromLegacyFormat(address), residenceType: undefined,
})); prefectureJa: "",
cityJa: "",
townJa: "",
...fromLegacyFormat(address),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only compute on mount
// Extract address field errors // Extract address field errors
const getError = (field: string): string | undefined => { const getError = (field: string): string | undefined => {
@ -176,7 +188,11 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
// Handle Japan address form changes // Handle Japan address form changes
const handleJapanAddressChange = useCallback( const handleJapanAddressChange = useCallback(
(data: JapanAddressFormData, _isComplete: boolean) => { (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 // Convert to legacy format and update parent form
const legacyAddress = toWhmcsFormat(data); const legacyAddress = toWhmcsFormat(data);
@ -214,11 +230,12 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
if (!address.country) { if (!address.country) {
setValue("address", { ...address, country: "JP", countryCode: "JP" }); setValue("address", { ...address, country: "JP", countryCode: "JP" });
} }
}, [address, setValue]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
return ( return (
<JapanAddressForm <JapanAddressForm
initialValues={japanData} initialValues={initialValues}
onChange={handleJapanAddressChange} onChange={handleJapanAddressChange}
errors={japanFieldErrors} errors={japanFieldErrors}
touched={japanFieldTouched} touched={japanFieldTouched}

View File

@ -12,7 +12,7 @@
* - Compatible with WHMCS and Salesforce field mapping * - Compatible with WHMCS and Salesforce field mapping
*/ */
import { useCallback, useState, useEffect } from "react"; import { useCallback, useState, useEffect, useRef } from "react";
import { Home, Building2, CheckCircle } from "lucide-react"; import { Home, Building2, CheckCircle } from "lucide-react";
import { Input } from "@/components/atoms"; import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
@ -97,6 +97,10 @@ export function JapanAddressForm({
// Track the ZIP code that was last looked up (to detect changes) // Track the ZIP code that was last looked up (to detect changes)
const [verifiedZipCode, setVerifiedZipCode] = useState<string>(""); const [verifiedZipCode, setVerifiedZipCode] = useState<string>("");
// Store onChange in ref to avoid it triggering useEffect re-runs
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Update address when initialValues change // Update address when initialValues change
useEffect(() => { useEffect(() => {
if (initialValues) { if (initialValues) {
@ -114,157 +118,117 @@ export function JapanAddressForm({
return touched[field] ? errors[field] : undefined; return touched[field] ? errors[field] : undefined;
}; };
// Notify parent of address changes with completeness check // Notify parent of address changes via useEffect (avoids setState during render)
const notifyChange = useCallback( useEffect(() => {
(next: InternalFormState, verified: boolean) => { const hasResidenceType =
const hasResidenceType = address.residenceType === RESIDENCE_TYPE.HOUSE ||
next.residenceType === RESIDENCE_TYPE.HOUSE || address.residenceType === RESIDENCE_TYPE.APARTMENT;
next.residenceType === RESIDENCE_TYPE.APARTMENT;
const baseFieldsFilled = const baseFieldsFilled =
next.postcode.trim() !== "" && address.postcode.trim() !== "" &&
next.prefecture.trim() !== "" && address.prefecture.trim() !== "" &&
next.city.trim() !== "" && address.city.trim() !== "" &&
next.town.trim() !== ""; address.town.trim() !== "";
// Room number is required for apartments // Room number is required for apartments
const roomNumberOk = const roomNumberOk =
next.residenceType !== RESIDENCE_TYPE.APARTMENT || (next.roomNumber?.trim() ?? "") !== ""; address.residenceType !== RESIDENCE_TYPE.APARTMENT ||
(address.roomNumber?.trim() ?? "") !== "";
// Must have verified address from ZIP lookup // Must have verified address from ZIP lookup
const isComplete = verified && hasResidenceType && baseFieldsFilled && roomNumberOk; const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk;
onChange?.(next as JapanAddressFormData, isComplete); // Use ref to avoid infinite loops when onChange changes reference
}, onChangeRef.current?.(address as JapanAddressFormData, isComplete);
[onChange] }, [address, isAddressVerified]);
);
// Handle ZIP code change - reset verification when ZIP changes // Handle ZIP code change - reset verification when ZIP changes
const handleZipChange = useCallback( const handleZipChange = useCallback(
(value: string) => { (value: string) => {
const normalizedNew = value.replace(/-/g, ""); const normalizedNew = value.replace(/-/g, "");
const normalizedVerified = verifiedZipCode.replace(/-/g, ""); const normalizedVerified = verifiedZipCode.replace(/-/g, "");
const shouldReset = normalizedNew !== normalizedVerified;
setAddress(prev => { if (shouldReset) {
// If ZIP code changed from verified one, reset address fields // Reset address fields when ZIP changes
if (normalizedNew !== normalizedVerified) { setIsAddressVerified(false);
const next: InternalFormState = { setAddress(prev => ({
...prev, ...prev,
postcode: value, postcode: value,
prefecture: "", prefecture: "",
prefectureJa: "", prefectureJa: "",
city: "", city: "",
cityJa: "", cityJa: "",
town: "", town: "",
townJa: "", townJa: "",
// Keep user-entered fields // Keep user-entered fields
buildingName: prev.buildingName, buildingName: prev.buildingName,
roomNumber: prev.roomNumber, roomNumber: prev.roomNumber,
residenceType: prev.residenceType, residenceType: prev.residenceType,
}; }));
setIsAddressVerified(false); } else {
notifyChange(next, false);
return next;
}
// Just update postcode formatting // Just update postcode formatting
const next = { ...prev, postcode: value }; setAddress(prev => ({ ...prev, postcode: value }));
notifyChange(next, isAddressVerified); }
return next;
});
}, },
[verifiedZipCode, isAddressVerified, notifyChange] [verifiedZipCode]
); );
// Handle address found from ZIP lookup // Handle address found from ZIP lookup
const handleAddressFound = useCallback( const handleAddressFound = useCallback((found: JapanPostAddress) => {
(found: JapanPostAddress) => { setAddress(prev => {
setAddress(prev => { setIsAddressVerified(true);
const next: InternalFormState = { setVerifiedZipCode(prev.postcode);
...prev, return {
// English (romanized) fields - for WHMCS ...prev,
prefecture: found.prefectureRoma, // English (romanized) fields - for WHMCS
city: found.cityRoma, prefecture: found.prefectureRoma,
town: found.townRoma, city: found.cityRoma,
// Japanese fields - for Salesforce town: found.townRoma,
prefectureJa: found.prefecture, // Japanese fields - for Salesforce
cityJa: found.city, prefectureJa: found.prefecture,
townJa: found.town, cityJa: found.city,
}; townJa: found.town,
};
setIsAddressVerified(true); });
setVerifiedZipCode(prev.postcode); }, []);
notifyChange(next, true);
return next;
});
},
[notifyChange]
);
// Handle lookup completion (success or failure) // Handle lookup completion (success or failure)
const handleLookupComplete = useCallback( const handleLookupComplete = useCallback((found: boolean) => {
(found: boolean) => { if (!found) {
if (!found) { // Clear address fields on failed lookup
// Clear address fields on failed lookup setIsAddressVerified(false);
setAddress(prev => { setAddress(prev => ({
const next: InternalFormState = { ...prev,
...prev, prefecture: "",
prefecture: "", prefectureJa: "",
prefectureJa: "", city: "",
city: "", cityJa: "",
cityJa: "", town: "",
town: "", townJa: "",
townJa: "", }));
}; }
setIsAddressVerified(false); }, []);
notifyChange(next, false);
return next;
});
}
},
[notifyChange]
);
// Handle residence type change // Handle residence type change
const handleResidenceTypeChange = useCallback( const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
(type: ResidenceType) => { setAddress(prev => ({
setAddress(prev => { ...prev,
const next: InternalFormState = { residenceType: type,
...prev, // Clear room number when switching to house
residenceType: type, roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
// Clear room number when switching to house }));
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber, }, []);
};
notifyChange(next, isAddressVerified);
return next;
});
},
[isAddressVerified, notifyChange]
);
// Handle building name change // Handle building name change
const handleBuildingNameChange = useCallback( const handleBuildingNameChange = useCallback((value: string) => {
(value: string) => { setAddress(prev => ({ ...prev, buildingName: value }));
setAddress(prev => { }, []);
const next = { ...prev, buildingName: value };
notifyChange(next, isAddressVerified);
return next;
});
},
[isAddressVerified, notifyChange]
);
// Handle room number change // Handle room number change
const handleRoomNumberChange = useCallback( const handleRoomNumberChange = useCallback((value: string) => {
(value: string) => { setAddress(prev => ({ ...prev, roomNumber: value }));
setAddress(prev => { }, []);
const next = { ...prev, roomNumber: value };
notifyChange(next, isAddressVerified);
return next;
});
},
[isAddressVerified, notifyChange]
);
const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT; const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT;
const hasResidenceTypeSelected = const hasResidenceTypeSelected =

View File

@ -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<SendVerificationCodeResponse> {
const response = await apiClient.POST<SendVerificationCodeResponse>(`${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<VerifyCodeResponse> {
const response = await apiClient.POST<VerifyCodeResponse>(`${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<QuickEligibilityResponse> {
const response = await apiClient.POST<QuickEligibilityResponse>(`${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<MaybeLaterResponse> {
const response = await apiClient.POST<MaybeLaterResponse>(`${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<AuthResponse> {
const response = await apiClient.POST<AuthResponse>(`${BASE_PATH}/complete-account`, {
body: request,
});
const data = getDataOrThrow(response, "Failed to complete account");
return authResponseSchema.parse(data);
}

View File

@ -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<GetStartedStep, React.ComponentType> = {
email: EmailStep,
verification: VerificationStep,
"account-status": AccountStatusStep,
"complete-account": CompleteAccountStep,
success: SuccessStep,
};
const stepTitles: Record<GetStartedStep, { title: string; subtitle: string }> = {
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 (
<div className="w-full">
<StepComponent />
</div>
);
}

View File

@ -0,0 +1 @@
export { GetStartedForm } from "./GetStartedForm";

View File

@ -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 (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Account Found</h3>
<p className="text-sm text-muted-foreground">
You already have a portal account with this email. Please log in to continue.
</p>
</div>
<Link href={`/auth/login?email=${encodeURIComponent(formData.email)}`}>
<Button className="w-full h-11">
Go to Login
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
);
}
// WHMCS exists but not mapped - need to link account
if (accountStatus === "whmcs_unmapped") {
return (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<UserCircleIcon className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Existing Account Found</h3>
<p className="text-sm text-muted-foreground">
We found an existing billing account with this email. Please verify your password to
link it to your new portal account.
</p>
</div>
<Link href={`/auth/migrate?email=${encodeURIComponent(formData.email)}`}>
<Button className="w-full h-11">
Link My Account
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
);
}
// SF exists but not mapped - complete account with pre-filled data
if (accountStatus === "sf_unmapped") {
return (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<DocumentCheckIcon className="h-8 w-8 text-primary" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">
{prefill?.firstName ? `Welcome back, ${prefill.firstName}!` : "Welcome Back!"}
</h3>
<p className="text-sm text-muted-foreground">
We found your information from a previous inquiry. Just set a password to complete your
account setup.
</p>
{prefill?.eligibilityStatus && (
<p className="text-sm font-medium text-success">
Eligibility Status: {prefill.eligibilityStatus}
</p>
)}
</div>
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
Complete My Account
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</div>
);
}
// New customer - proceed to full signup
return (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Email Verified!</h3>
<p className="text-sm text-muted-foreground">
Great! Let&apos;s set up your account so you can access all our services.
</p>
</div>
<Button onClick={() => goToStep("complete-account")} className="w-full h-11">
<UserPlusIcon className="h-4 w-4 mr-2" />
Create My Account
</Button>
<p className="text-xs text-muted-foreground">
Want to check service availability first?{" "}
<Link href="/services/internet" className="text-primary hover:underline">
Check eligibility
</Link>{" "}
without creating an account.
</p>
</div>
);
}

View File

@ -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<FormErrors>({});
// 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 (
<div className="space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">
{hasPrefill
? `Welcome back, ${prefill?.firstName}! Just a few more details.`
: "Fill in your details to create your account."}
</p>
</div>
{/* Pre-filled info display (SF-only users) */}
{hasPrefill && (
<div className="p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-1">Account for:</p>
<p className="font-medium text-foreground">
{prefill?.firstName} {prefill?.lastName}
</p>
{prefill?.address && (
<p className="text-sm text-muted-foreground mt-1">
{prefill.address.city}, {prefill.address.state}
</p>
)}
</div>
)}
{/* Name fields (new customers only) */}
{isNewCustomer && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">
First Name <span className="text-danger">*</span>
</Label>
<Input
id="firstName"
value={firstName}
onChange={e => {
setFirstName(e.target.value);
setLocalErrors(prev => ({ ...prev, firstName: undefined }));
}}
placeholder="Taro"
disabled={loading}
error={localErrors.firstName}
/>
{localErrors.firstName && (
<p className="text-sm text-danger">{localErrors.firstName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="lastName">
Last Name <span className="text-danger">*</span>
</Label>
<Input
id="lastName"
value={lastName}
onChange={e => {
setLastName(e.target.value);
setLocalErrors(prev => ({ ...prev, lastName: undefined }));
}}
placeholder="Yamada"
disabled={loading}
error={localErrors.lastName}
/>
{localErrors.lastName && (
<p className="text-sm text-danger">{localErrors.lastName}</p>
)}
</div>
</div>
{/* Address Form (new customers only) */}
<div className="space-y-2">
<Label>
Address <span className="text-danger">*</span>
</Label>
<JapanAddressForm onChange={handleAddressChange} disabled={loading} />
{localErrors.address && <p className="text-sm text-danger">{localErrors.address}</p>}
</div>
</>
)}
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
Password <span className="text-danger">*</span>
</Label>
<Input
id="password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, password: undefined }));
}}
placeholder="Create a strong password"
disabled={loading}
error={localErrors.password}
autoComplete="new-password"
/>
{localErrors.password && <p className="text-sm text-danger">{localErrors.password}</p>}
<p className="text-xs text-muted-foreground">
At least 8 characters with uppercase, lowercase, and numbers
</p>
</div>
{/* Confirm Password */}
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Confirm Password <span className="text-danger">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value);
setLocalErrors(prev => ({ ...prev, confirmPassword: undefined }));
}}
placeholder="Confirm your password"
disabled={loading}
error={localErrors.confirmPassword}
autoComplete="new-password"
/>
{localErrors.confirmPassword && (
<p className="text-sm text-danger">{localErrors.confirmPassword}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">
Phone Number <span className="text-danger">*</span>
</Label>
<Input
id="phone"
type="tel"
value={phone}
onChange={e => {
setPhone(e.target.value);
setLocalErrors(prev => ({ ...prev, phone: undefined }));
}}
placeholder="090-1234-5678"
disabled={loading}
error={localErrors.phone}
/>
{localErrors.phone && <p className="text-sm text-danger">{localErrors.phone}</p>}
</div>
{/* Date of Birth */}
<div className="space-y-2">
<Label htmlFor="dateOfBirth">
Date of Birth <span className="text-danger">*</span>
</Label>
<Input
id="dateOfBirth"
type="date"
value={dateOfBirth}
onChange={e => {
setDateOfBirth(e.target.value);
setLocalErrors(prev => ({ ...prev, dateOfBirth: undefined }));
}}
disabled={loading}
error={localErrors.dateOfBirth}
max={new Date().toISOString().split("T")[0]}
/>
{localErrors.dateOfBirth && (
<p className="text-sm text-danger">{localErrors.dateOfBirth}</p>
)}
</div>
{/* Gender */}
<div className="space-y-2">
<Label>
Gender <span className="text-danger">*</span>
</Label>
<div className="flex gap-4">
{(["male", "female", "other"] as const).map(option => (
<label key={option} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="gender"
value={option}
checked={gender === option}
onChange={() => {
setGender(option);
setLocalErrors(prev => ({ ...prev, gender: undefined }));
}}
disabled={loading}
className="h-4 w-4 text-primary focus:ring-primary"
/>
<span className="text-sm capitalize">{option}</span>
</label>
))}
</div>
{localErrors.gender && <p className="text-sm text-danger">{localErrors.gender}</p>}
</div>
{/* Terms & Marketing */}
<div className="space-y-3">
<div className="flex items-start gap-2">
<Checkbox
id="acceptTerms"
checked={acceptTerms}
onChange={e => {
setAcceptTerms(e.target.checked);
setLocalErrors(prev => ({ ...prev, acceptTerms: undefined }));
}}
disabled={loading}
/>
<Label htmlFor="acceptTerms" className="text-sm font-normal leading-tight cursor-pointer">
I accept the{" "}
<a
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Privacy Policy
</a>{" "}
<span className="text-danger">*</span>
</Label>
</div>
{localErrors.acceptTerms && (
<p className="text-sm text-danger ml-6">{localErrors.acceptTerms}</p>
)}
<div className="flex items-start gap-2">
<Checkbox
id="marketingConsent"
checked={marketingConsent}
onChange={e => setMarketingConsent(e.target.checked)}
disabled={loading}
/>
<Label
htmlFor="marketingConsent"
className="text-sm font-normal leading-tight cursor-pointer"
>
I would like to receive marketing emails and updates
</Label>
</div>
</div>
{/* Error display */}
{error && (
<div className="p-3 rounded-lg bg-danger/10 border border-danger/20">
<p className="text-sm text-danger">{error}</p>
</div>
)}
{/* Actions */}
<div className="space-y-3">
<Button
type="button"
onClick={handleSubmit}
disabled={loading || !canSubmit}
loading={loading}
className="w-full h-11"
>
{loading ? "Creating Account..." : "Create Account"}
</Button>
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={loading}
className="w-full"
>
Go Back
</Button>
</div>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={e => {
setEmail(e.target.value);
setLocalError(null);
clearError();
}}
onKeyDown={handleKeyDown}
disabled={loading}
error={displayError}
autoComplete="email"
autoFocus
/>
{displayError && (
<p className="text-sm text-danger" role="alert">
{displayError}
</p>
)}
</div>
<div className="text-sm text-muted-foreground">
<p>
We&apos;ll send a verification code to confirm your email address. This helps us keep your
account secure.
</p>
</div>
<Button
type="button"
onClick={handleSubmit}
disabled={loading || !email.trim()}
loading={loading}
className="w-full h-11"
>
{loading ? "Sending Code..." : "Send Verification Code"}
</Button>
</div>
);
}

View File

@ -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 (
<div className="space-y-6 text-center">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center">
<CheckCircleIcon className="h-8 w-8 text-success" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Account Created!</h3>
<p className="text-sm text-muted-foreground">
Your account has been set up successfully. You can now access all our services.
</p>
</div>
<div className="space-y-3">
<Link href="/account/dashboard">
<Button className="w-full h-11">
Go to Dashboard
<ArrowRightIcon className="h-4 w-4 ml-2" />
</Button>
</Link>
<Link href="/services/internet">
<Button variant="outline" className="w-full h-11">
Check Internet Availability
</Button>
</Link>
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-6">
<div className="text-center space-y-2">
<p className="text-sm text-muted-foreground">Enter the 6-digit code sent to</p>
<p className="font-medium text-foreground">{formData.email}</p>
</div>
<OtpInput
value={code}
onChange={handleCodeChange}
onComplete={handleCodeComplete}
disabled={loading}
error={error || undefined}
autoFocus
/>
{attemptsRemaining !== null && attemptsRemaining < 3 && (
<p className="text-sm text-warning text-center">
{attemptsRemaining} {attemptsRemaining === 1 ? "attempt" : "attempts"} remaining
</p>
)}
<div className="space-y-3">
<Button
type="button"
onClick={handleVerify}
disabled={loading || code.length !== 6}
loading={loading}
className="w-full h-11"
>
{loading ? "Verifying..." : "Verify Code"}
</Button>
<div className="flex items-center justify-between">
<Button
type="button"
variant="ghost"
onClick={goBack}
disabled={loading}
className="text-sm"
>
Change email
</Button>
<Button
type="button"
variant="ghost"
onClick={handleResend}
disabled={loading || resending}
className="text-sm"
>
{resending ? "Sending..." : "Resend code"}
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
The code expires in 10 minutes. Check your spam folder if you don&apos;t see it.
</p>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { EmailStep } from "./EmailStep";
export { VerificationStep } from "./VerificationStep";
export { AccountStatusStep } from "./AccountStatusStep";
export { CompleteAccountStep } from "./CompleteAccountStep";
export { SuccessStep } from "./SuccessStep";

View File

@ -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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="space-y-2">
<div className="flex justify-center gap-2">
{digits.map((digit, index) => (
<input
key={index}
ref={el => {
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}`}
/>
))}
</div>
{error && (
<p className="text-sm text-danger text-center" role="alert">
{error}
</p>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { OtpInput } from "./OtpInput";

View File

@ -0,0 +1,2 @@
export { GetStartedForm } from "./GetStartedForm";
export { OtpInput } from "./OtpInput";

View File

@ -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";

View File

@ -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<boolean>;
verifyCode: (code: string) => Promise<AccountStatus | null>;
completeAccount: () => Promise<AuthResponse | null>;
// Navigation
goToStep: (step: GetStartedStep) => void;
goBack: () => void;
// Form updates
updateFormData: (data: Partial<GetStartedFormData>) => 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<GetStartedState>()((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<GetStartedFormData>) => {
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 });
},
}));

View File

@ -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 (
<AuthLayout title={meta.title} subtitle={meta.subtitle} wide>
<GetStartedForm onStepChange={handleStepChange} />
</AuthLayout>
);
}
export default GetStartedView;

View File

@ -0,0 +1 @@
export { GetStartedView } from "./GetStartedView";

View File

@ -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 (
<div
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-full border",
config.bg,
config.border
)}
>
<Icon className={cn("h-4 w-4", config.text)} />
<span className={cn("font-semibold text-sm", config.text)}>{config.label}</span>
{status === "eligible" && speed && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-sm text-foreground font-medium">Up to {speed}</span>
</>
)}
</div>
);
}

View File

@ -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 (
<div className="bg-warning/5 border border-warning/20 rounded-xl p-6 text-center">
<TriangleAlert className="h-12 w-12 text-warning mx-auto mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">Service not available</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
{rejectionNotes ||
"Our review determined that NTT fiber service isn't available at your address."}
</p>
<Button as="a" href="/account/support/new" variant="outline">
Contact support
</Button>
</div>
);
}

View File

@ -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 (
<>
<div className="bg-info-soft/30 border border-info/20 rounded-xl p-8 mb-6 text-center max-w-2xl mx-auto">
<Clock className="h-16 w-16 text-info mx-auto mb-6" />
<h2 className="text-2xl font-semibold text-foreground mb-3">Verification in Progress</h2>
<p className="text-base text-muted-foreground mb-4 leading-relaxed">
We're currently verifying NTT service availability at your registered address.
<br />
This manual check ensures we offer you the correct fiber connection type.
</p>
<div className="inline-flex flex-col items-center p-4 bg-background rounded-lg border border-border">
<span className="text-sm font-medium text-foreground mb-1">Estimated time</span>
<span className="text-sm text-muted-foreground">1-2 business days</span>
</div>
{requestedAt && (
<p className="text-xs text-muted-foreground mt-6">
Request submitted: {formatIsoDate(requestedAt)}
</p>
)}
</div>
<div className="text-center">
<Button as="a" href={servicesBasePath} variant="outline">
Back to Services
</Button>
</div>
</>
);
}

View File

@ -6,74 +6,14 @@ import { ArrowRight, Check, ShieldCheck } from "lucide-react";
import type { VpnCatalogProduct } from "@customer-portal/domain/services"; import type { VpnCatalogProduct } from "@customer-portal/domain/services";
import { CardPricing } from "@/features/services/components/base/CardPricing"; import { CardPricing } from "@/features/services/components/base/CardPricing";
import { cn } from "@/shared/utils/cn"; import { cn } from "@/shared/utils/cn";
import { getVpnRegionConfig } from "@/features/services/utils";
interface VpnPlanCardProps { interface VpnPlanCardProps {
plan: VpnCatalogProduct; 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) { export function VpnPlanCard({ plan }: VpnPlanCardProps) {
const region = getRegionData(plan.name); const region = getVpnRegionConfig(plan.name);
const isUS = region.accent === "blue"; const isUS = region.accent === "blue";
const isUK = region.accent === "red"; const isUK = region.accent === "red";

View File

@ -1,2 +1,4 @@
export * from "./services.utils"; export * from "./services.utils";
export * from "./pricing"; export * from "./pricing";
export * from "./internet-config";
export * from "./service-features";

View File

@ -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<string, number> = {
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<InternetTier, TierConfig> = {
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<InternetTier, string> = {
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<InternetTier, string[]> = {
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<string, Omit<OfferingConfig, "offeringType">> = {
"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<string, string> = {
"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<OfferingConfig, "offeringType"> | 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<T extends { internetPlanTier?: string }>(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<string, EligibilityDisplayInfo> = {
"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<T extends { internetOfferingType?: string }>(
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);
}

View File

@ -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: <Wifi className="h-6 w-6" />,
title: "NTT Optical Fiber",
description: "Japan's most reliable network with speeds up to 10Gbps",
highlight: "99.9% uptime",
},
{
icon: <Zap className="h-6 w-6" />,
title: "IPv6/IPoE Ready",
description: "Next-gen protocol for congestion-free browsing",
highlight: "No peak-hour slowdowns",
},
{
icon: <Languages className="h-6 w-6" />,
title: "Full English Support",
description: "Native English service for setup, billing & technical help",
highlight: "No language barriers",
},
{
icon: <FileText className="h-6 w-6" />,
title: "One Bill, One Provider",
description: "NTT line + ISP + equipment bundled with simple billing",
highlight: "No hidden fees",
},
{
icon: <Wrench className="h-6 w-6" />,
title: "On-site Support",
description: "Technicians can visit for installation & troubleshooting",
highlight: "Professional setup",
},
{
icon: <Globe className="h-6 w-6" />,
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: <Router className="h-6 w-6" />,
title: "Pre-configured Router",
description: "Ready to use out of the box — just plug in and connect",
highlight: "Plug & play",
},
{
icon: <Globe className="h-6 w-6" />,
title: "US & UK Servers",
description: "Access content from San Francisco or London regions",
highlight: "2 locations",
},
{
icon: <MonitorPlay className="h-6 w-6" />,
title: "Streaming Ready",
description: "Works with Apple TV, Roku, Amazon Fire, and more",
highlight: "All devices",
},
{
icon: <Wifi className="h-6 w-6" />,
title: "Separate Network",
description: "VPN runs on dedicated WiFi, keep regular internet normal",
highlight: "No interference",
},
{
icon: <Package className="h-6 w-6" />,
title: "Router Rental Included",
description: "No equipment purchase — router rental is part of the plan",
highlight: "No hidden costs",
},
{
icon: <Headphones className="h-6 w-6" />,
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<string, VpnRegionConfig> = {
"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 };
}

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useRouter, useSearchParams } from "next/navigation"; 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 { useAccountInternetCatalog } from "@/features/services/hooks";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import type { import type {
@ -12,7 +12,6 @@ import type {
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes"; import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes";
@ -22,103 +21,37 @@ import {
} from "@/features/services/components/internet/InternetOfferingCard"; } from "@/features/services/components/internet/InternetOfferingCard";
import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans"; import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans";
import { PlanComparisonGuide } from "@/features/services/components/internet/PlanComparisonGuide"; 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 { useInternetEligibility } from "@/features/services/hooks";
import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store"; 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 { // Helper Functions
offeringType: string; // ============================================================================
title: string;
speedBadge: string;
description: string;
iconType: "home" | "apartment";
isPremium: boolean;
displayOrder: number;
isAlternative?: boolean;
alternativeNote?: string;
}
const OFFERING_CONFIGS: Record<string, Omit<OfferingConfig, "offeringType">> = {
"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,
},
};
function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] { function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] {
const filtered = plans.filter(p => p.internetOfferingType === offeringType); 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[] = []; const result: TierInfo[] = [];
for (const tier of tierOrder) { for (const tier of TIER_ORDER) {
const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase()); const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase());
if (!plan) continue; if (!plan) continue;
const config = tierDescriptions[tier]; const config = TIER_CONFIGS[tier];
result.push({ result.push({
tier, tier,
planSku: plan.sku, planSku: plan.sku,
monthlyPrice: plan.monthlyPrice ?? 0, monthlyPrice: plan.monthlyPrice ?? 0,
description: config.description, description: config.description,
features: config.features, features: config.features,
recommended: tier === "Gold", recommended: config.isRecommended ?? false,
pricingNote: config.pricingNote, pricingNote: config.pricingNote,
}); });
} }
@ -130,177 +63,186 @@ function getSetupFee(installations: InternetInstallationCatalogItem[]): number {
return basic?.oneTimePrice ?? 22800; return basic?.oneTimePrice ?? 22800;
} }
function getAvailableOfferings( // ============================================================================
eligibility: string | null, // Loading Skeleton Component
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;
function InternetPlansLoadingSkeleton({ servicesBasePath }: { servicesBasePath: string }) {
return ( return (
<div <div className="max-w-4xl mx-auto px-4">
className={cn( <ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-4" />
"inline-flex items-center gap-2 px-4 py-2 rounded-full border", <div className="text-center mb-12">
config.bg, <Skeleton className="h-10 w-96 mx-auto mb-4" />
config.border <Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
)} </div>
> <div className="space-y-4">
<Icon className={cn("h-4 w-4", config.text)} /> {[1, 2].map(i => (
<span className={cn("font-semibold text-sm", config.text)}>{config.label}</span> <div
{status === "eligible" && speed && ( key={i}
<> className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
<span className="text-muted-foreground">·</span> >
<span className="text-sm text-foreground font-medium">Up to {speed}</span> <div className="flex items-start gap-4">
</> <Skeleton className="h-12 w-12 rounded-xl" />
)} <div className="flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
))}
</div>
</div> </div>
); );
} }
// ============================================================================
// 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 */}
<div className="mb-6">
<PlanComparisonGuide />
</div>
{/* Speed options header (only if multiple) */}
{offeringCards.length > 1 && (
<div className="mb-4">
<h2 className="text-lg font-semibold text-foreground">Choose your speed</h2>
<p className="text-sm text-muted-foreground">Your address supports multiple options</p>
</div>
)}
{/* Offering cards */}
<div className="space-y-4 mb-6">
{offeringCards.map(card => (
<div key={card.offeringType}>
{card.isAlternative && (
<div className="flex items-center gap-2 mb-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground">
Alternative option
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<InternetOfferingCard
offeringType={card.offeringType}
title={card.title}
speedBadge={card.speedBadge}
description={card.alternativeNote ?? card.description}
iconType={card.iconType}
startingPrice={card.startingPrice}
setupFee={card.setupFee}
tiers={card.tiers}
ctaPath={card.ctaPath}
isPremium={card.isPremium}
defaultExpanded={false}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet ? "Contact support for additional lines" : undefined
}
/>
</div>
))}
</div>
{/* Important notes - collapsed by default */}
<InternetImportantNotes />
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-10"
/>
</>
);
}
// ============================================================================
// No Plans Available Component
// ============================================================================
function NoPlansAvailable({ servicesBasePath }: { servicesBasePath: string }) {
return (
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<Server className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans at this time.
</p>
<ServicesBackLink href={servicesBasePath} label="Back to Services" align="center" />
</div>
</div>
);
}
// ============================================================================
// Active Internet Warning Component
// ============================================================================
function ActiveInternetWarning() {
return (
<AlertBanner variant="warning" title="Active subscription found" className="mb-6">
You already have an internet subscription. For additional residences, please{" "}
<a href="/account/support/new" className="underline font-medium">
contact support
</a>
.
</AlertBanner>
);
}
// ============================================================================
// Main Container Component
// ============================================================================
export function InternetPlansContainer() { export function InternetPlansContainer() {
const router = useRouter(); const router = useRouter();
const servicesBasePath = useServicesBasePath(); const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user } = useAuthSession(); const { user } = useAuthSession();
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth); const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
// Data fetching
const { data, error } = useAccountInternetCatalog(); const { data, error } = useAccountInternetCatalog();
// Simple loading check: show skeleton until we have data or an error
const isLoading = !data && !error; const isLoading = !data && !error;
const eligibilityQuery = useInternetEligibility(); const eligibilityQuery = useInternetEligibility();
const eligibilityLoading = eligibilityQuery.isLoading; const eligibilityLoading = eligibilityQuery.isLoading;
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
const installations: InternetInstallationCatalogItem[] = useMemo( // Memoized data
() => data?.installations ?? [], const plans = useMemo(() => data?.plans ?? [], [data?.plans]);
[data?.installations] const installations = useMemo(() => data?.installations ?? [], [data?.installations]);
);
// Check for active subscriptions
const { data: activeSubs } = useActiveSubscriptions(); const { data: activeSubs } = useActiveSubscriptions();
const hasActiveInternet = useMemo( const hasActiveInternet = useMemo(
() => () =>
@ -316,6 +258,7 @@ export function InternetPlansContainer() {
[activeSubs] [activeSubs]
); );
// Eligibility state
const eligibilityValue = eligibilityQuery.data?.eligibility; const eligibilityValue = eligibilityQuery.data?.eligibility;
const eligibilityStatus = eligibilityQuery.data?.status; const eligibilityStatus = eligibilityQuery.data?.status;
const requestedAt = eligibilityQuery.data?.requestedAt; const requestedAt = eligibilityQuery.data?.requestedAt;
@ -329,9 +272,11 @@ export function InternetPlansContainer() {
const isNotRequested = eligibilityStatus === "not_requested"; const isNotRequested = eligibilityStatus === "not_requested";
const isIneligible = eligibilityStatus === "ineligible"; const isIneligible = eligibilityStatus === "ineligible";
// URL params for auto-redirect
const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1"; const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1";
const autoPlanSku = searchParams?.get("planSku"); const autoPlanSku = searchParams?.get("planSku");
// Computed values
const eligibility = useMemo(() => { const eligibility = useMemo(() => {
if (!isEligible) return null; if (!isEligible) return null;
return eligibilityValue?.trim() ?? null; return eligibilityValue?.trim() ?? null;
@ -365,18 +310,15 @@ export function InternetPlansContainer() {
.filter(card => card.tiers.length > 0); .filter(card => card.tiers.length > 0);
}, [availableOfferings, plans, setupFee, servicesBasePath]); }, [availableOfferings, plans, setupFee, servicesBasePath]);
// Logic to handle check availability click // Handlers
const handleCheckAvailability = async (e?: React.MouseEvent) => { const handleCheckAvailability = (e?: React.MouseEvent) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
const target = `${servicesBasePath}/internet/request`; router.push(`${servicesBasePath}/internet/request`);
router.push(target);
}; };
// Auto eligibility request redirect // Auto eligibility request redirect
useEffect(() => { useEffect(() => {
if (!autoEligibilityRequest) return; if (!autoEligibilityRequest || !hasCheckedAuth || !user) return;
if (!hasCheckedAuth) return;
if (!user) return;
const params = new URLSearchParams(); const params = new URLSearchParams();
if (autoPlanSku) params.set("planSku", autoPlanSku); if (autoPlanSku) params.set("planSku", autoPlanSku);
@ -385,40 +327,6 @@ export function InternetPlansContainer() {
router.replace(`${servicesBasePath}/internet/request${query.length > 0 ? `?${query}` : ""}`); router.replace(`${servicesBasePath}/internet/request${query.length > 0 ? `?${query}` : ""}`);
}, [autoEligibilityRequest, autoPlanSku, hasCheckedAuth, servicesBasePath, user, router]); }, [autoEligibilityRequest, autoPlanSku, hasCheckedAuth, servicesBasePath, user, router]);
// Loading state
if (isLoading || error) {
return (
<div className="max-w-4xl mx-auto px-4 pt-8">
<AsyncBlock isLoading={false} error={error}>
<div className="max-w-4xl mx-auto px-4">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-4" />
<div className="text-center mb-12">
<Skeleton className="h-10 w-96 mx-auto mb-4" />
<Skeleton className="h-4 w-[32rem] max-w-full mx-auto" />
</div>
<div className="space-y-4">
{[1, 2].map(i => (
<div
key={i}
className="bg-card rounded-xl border border-border p-6 shadow-[var(--cp-shadow-1)]"
>
<div className="flex items-start gap-4">
<Skeleton className="h-12 w-12 rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
<Skeleton className="h-6 w-32" />
</div>
</div>
</div>
))}
</div>
</div>
</AsyncBlock>
</div>
);
}
// Determine current status for the badge // Determine current status for the badge
const currentStatus = isEligible const currentStatus = isEligible
? "eligible" ? "eligible"
@ -428,21 +336,22 @@ export function InternetPlansContainer() {
? "ineligible" ? "ineligible"
: "not_requested"; : "not_requested";
// Case 1: Unverified / Not Requested - Show Public Content exactly // Loading state
if (isLoading || error) {
return (
<div className="max-w-4xl mx-auto px-4 pt-8">
<AsyncBlock isLoading={false} error={error}>
<InternetPlansLoadingSkeleton servicesBasePath={servicesBasePath} />
</AsyncBlock>
</div>
);
}
// Not Requested - Show Public Content
if (isNotRequested) { if (isNotRequested) {
return ( return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
{/* Already has internet warning */} {hasActiveInternet && <ActiveInternetWarning />}
{hasActiveInternet && (
<AlertBanner variant="warning" title="Active subscription found" className="mb-6">
You already have an internet subscription. For additional residences, please{" "}
<a href="/account/support/new" className="underline font-medium">
contact support
</a>
.
</AlertBanner>
)}
<PublicInternetPlansContent <PublicInternetPlansContent
onCtaClick={handleCheckAvailability} onCtaClick={handleCheckAvailability}
ctaLabel="Check Availability" ctaLabel="Check Availability"
@ -451,12 +360,12 @@ export function InternetPlansContainer() {
); );
} }
// Case 2: Standard Portal View (Pending, Eligible, Ineligible, Loading) // Standard Portal View (Pending, Eligible, Ineligible)
return ( return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
<ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-6" /> <ServicesBackLink href={servicesBasePath} label="Back to Services" className="mb-6" />
{/* Hero section - compact (for portal view) */} {/* Hero section */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2"> <h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
Your Internet Options Your Internet Options
@ -470,7 +379,7 @@ export function InternetPlansContainer() {
<EligibilityStatusBadge status={currentStatus} speed={eligibilityDisplay?.speed} /> <EligibilityStatusBadge status={currentStatus} speed={eligibilityDisplay?.speed} />
)} )}
{/* Loading states */} {/* Loading state */}
{eligibilityLoading && ( {eligibilityLoading && (
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-border bg-muted">
<div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <div className="h-4 w-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
@ -479,143 +388,27 @@ export function InternetPlansContainer() {
)} )}
</div> </div>
{/* Already has internet warning */} {hasActiveInternet && <ActiveInternetWarning />}
{hasActiveInternet && (
<AlertBanner variant="warning" title="Active subscription found" className="mb-6">
You already have an internet subscription. For additional residences, please{" "}
<a href="/account/support/new" className="underline font-medium">
contact support
</a>
.
</AlertBanner>
)}
{/* ELIGIBLE STATE - Clean & Personalized */} {/* Eligible State */}
{isEligible && eligibilityDisplay && offeringCards.length > 0 && ( {isEligible && eligibilityDisplay && offeringCards.length > 0 && (
<> <InternetEligibleState
{/* Plan comparison guide */} offeringCards={offeringCards}
<div className="mb-6"> hasActiveInternet={hasActiveInternet}
<PlanComparisonGuide /> servicesBasePath={servicesBasePath}
</div> />
{/* Speed options header (only if multiple) */}
{offeringCards.length > 1 && (
<div className="mb-4">
<h2 className="text-lg font-semibold text-foreground">Choose your speed</h2>
<p className="text-sm text-muted-foreground">
Your address supports multiple options
</p>
</div>
)}
{/* Offering cards */}
<div className="space-y-4 mb-6">
{offeringCards.map(card => (
<div key={card.offeringType}>
{card.isAlternative && (
<div className="flex items-center gap-2 mb-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs font-medium text-muted-foreground">
Alternative option
</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
<InternetOfferingCard
offeringType={card.offeringType}
title={card.title}
speedBadge={card.speedBadge}
description={card.alternativeNote ?? card.description}
iconType={card.iconType}
startingPrice={card.startingPrice}
setupFee={card.setupFee}
tiers={card.tiers}
ctaPath={card.ctaPath}
isPremium={card.isPremium}
defaultExpanded={false}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet ? "Contact support for additional lines" : undefined
}
/>
</div>
))}
</div>
{/* Important notes - collapsed by default */}
<InternetImportantNotes />
<ServicesBackLink
href={servicesBasePath}
label="Back to Services"
align="center"
className="mt-10"
/>
</>
)} )}
{/* PENDING STATE - Clean Status View */} {/* Pending State */}
{isPending && ( {isPending && (
<> <InternetPendingState requestedAt={requestedAt} servicesBasePath={servicesBasePath} />
<div className="bg-info-soft/30 border border-info/20 rounded-xl p-8 mb-6 text-center max-w-2xl mx-auto">
<Clock className="h-16 w-16 text-info mx-auto mb-6" />
<h2 className="text-2xl font-semibold text-foreground mb-3">
Verification in Progress
</h2>
<p className="text-base text-muted-foreground mb-4 leading-relaxed">
We're currently verifying NTT service availability at your registered address.
<br />
This manual check ensures we offer you the correct fiber connection type.
</p>
<div className="inline-flex flex-col items-center p-4 bg-background rounded-lg border border-border">
<span className="text-sm font-medium text-foreground mb-1">Estimated time</span>
<span className="text-sm text-muted-foreground">1-2 business days</span>
</div>
{requestedAt && (
<p className="text-xs text-muted-foreground mt-6">
Request submitted: {formatIsoDate(requestedAt)}
</p>
)}
</div>
<div className="text-center">
<Button as="a" href={servicesBasePath} variant="outline">
Back to Services
</Button>
</div>
</>
)} )}
{/* INELIGIBLE STATE */} {/* Ineligible State */}
{isIneligible && ( {isIneligible && <InternetIneligibleState rejectionNotes={rejectionNotes} />}
<div className="bg-warning/5 border border-warning/20 rounded-xl p-6 text-center">
<TriangleAlert className="h-12 w-12 text-warning mx-auto mb-4" />
<h2 className="text-lg font-semibold text-foreground mb-2">Service not available</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-md mx-auto">
{rejectionNotes ||
"Our review determined that NTT fiber service isn't available at your address."}
</p>
<Button as="a" href="/account/support/new" variant="outline">
Contact support
</Button>
</div>
)}
{/* No plans available */} {/* No plans available */}
{plans.length === 0 && !isLoading && ( {plans.length === 0 && !isLoading && <NoPlansAvailable servicesBasePath={servicesBasePath} />}
<div className="text-center py-16">
<div className="bg-card rounded-2xl shadow-[var(--cp-shadow-1)] border border-border p-12 max-w-md mx-auto">
<Server className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
<h3 className="text-xl font-semibold text-foreground mb-2">No Plans Available</h3>
<p className="text-muted-foreground mb-8">
We couldn&apos;t find any internet plans at this time.
</p>
<ServicesBackLink href={servicesBasePath} label="Back to Services" align="center" />
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -1,18 +1,7 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { import { Wifi, MapPin, Settings, Calendar, Router } from "lucide-react";
Wifi,
Zap,
Languages,
FileText,
Wrench,
Globe,
MapPin,
Settings,
Calendar,
Router,
} from "lucide-react";
import { usePublicInternetCatalog } from "@/features/services/hooks"; import { usePublicInternetCatalog } from "@/features/services/hooks";
import type { import type {
InternetPlanCatalogItem, InternetPlanCatalogItem,
@ -24,13 +13,18 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard"; import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard";
import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard"; import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard";
import { import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights";
ServiceHighlights,
HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights";
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks"; import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA"; import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ"; 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 // Types
interface GroupedOffering { interface GroupedOffering {
@ -129,45 +123,6 @@ export function PublicInternetPlansContent({
const defaultCtaPath = `${servicesBasePath}/internet/configure`; const defaultCtaPath = `${servicesBasePath}/internet/configure`;
const ctaPath = propCtaPath ?? defaultCtaPath; const ctaPath = propCtaPath ?? defaultCtaPath;
const internetFeatures: HighlightFeature[] = [
{
icon: <Wifi className="h-6 w-6" />,
title: "NTT Optical Fiber",
description: "Japan's most reliable network with speeds up to 10Gbps",
highlight: "99.9% uptime",
},
{
icon: <Zap className="h-6 w-6" />,
title: "IPv6/IPoE Ready",
description: "Next-gen protocol for congestion-free browsing",
highlight: "No peak-hour slowdowns",
},
{
icon: <Languages className="h-6 w-6" />,
title: "Full English Support",
description: "Native English service for setup, billing & technical help",
highlight: "No language barriers",
},
{
icon: <FileText className="h-6 w-6" />,
title: "One Bill, One Provider",
description: "NTT line + ISP + equipment bundled with simple billing",
highlight: "No hidden fees",
},
{
icon: <Wrench className="h-6 w-6" />,
title: "On-site Support",
description: "Technicians can visit for installation & troubleshooting",
highlight: "Professional setup",
},
{
icon: <Globe className="h-6 w-6" />,
title: "Flexible Options",
description: "Multiple ISP configs available, IPv4/PPPoE if needed",
highlight: "Customizable",
},
];
// Group services items by offering type // Group services items by offering type
const groupedOfferings = useMemo(() => { const groupedOfferings = useMemo(() => {
if (!servicesCatalog?.plans) return []; if (!servicesCatalog?.plans) return [];
@ -202,38 +157,11 @@ export function PublicInternetPlansContent({
} }
} }
// Define offering metadata // Use shared offering configs with display order override for public view
// Order: Home 10G first (premium), then Home 1G, then consolidated Apartment const offeringDisplayOrder: Record<string, number> = {
const offeringMeta: Record< "Home 10G": 1,
string, "Home 1G": 2,
{ Apartment: 3,
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,
},
}; };
// Process Home offerings // Process Home offerings
@ -241,28 +169,29 @@ export function PublicInternetPlansContent({
// Skip apartment types - we'll handle them separately // Skip apartment types - we'll handle them separately
if (apartmentTypes.includes(offeringType)) continue; if (apartmentTypes.includes(offeringType)) continue;
const meta = offeringMeta[offeringType]; const meta = OFFERING_CONFIGS[offeringType];
if (!meta) continue; if (!meta) continue;
// Sort plans by tier: Silver, Gold, Platinum // Sort plans by tier using shared tier order
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
const sortedPlans = [...plans].sort( const sortedPlans = [...plans].sort(
(a, b) => (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 // Calculate starting price
const startingPrice = Math.min(...sortedPlans.map(p => p.monthlyPrice ?? 0)); 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); 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 => ({ const tiers: TierInfo[] = sortedPlans.map(plan => ({
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
monthlyPrice: plan.monthlyPrice ?? 0, monthlyPrice: plan.monthlyPrice ?? 0,
description: getTierDescription(plan.internetPlanTier ?? ""), description: getTierDescription(plan.internetPlanTier ?? "", true),
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), features:
plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true),
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, 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) // Add consolidated Apartment offering (use any apartment plan for tiers - prices are the same)
if (apartmentPlans.length > 0) { if (apartmentPlans.length > 0) {
const meta = offeringMeta["Apartment"]; const meta = OFFERING_CONFIGS["Apartment"];
// Get unique tiers from apartment plans (they all have same prices) // Get unique tiers from apartment plans (they all have same prices)
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
const uniqueTiers = new Map<string, InternetPlanCatalogItem>(); const uniqueTiers = new Map<string, InternetPlanCatalogItem>();
for (const plan of apartmentPlans) { for (const plan of apartmentPlans) {
@ -297,7 +225,8 @@ export function PublicInternetPlansContent({
const sortedTierPlans = Array.from(uniqueTiers.values()).sort( const sortedTierPlans = Array.from(uniqueTiers.values()).sort(
(a, b) => (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)); const startingPrice = Math.min(...sortedTierPlans.map(p => p.monthlyPrice ?? 0));
@ -305,17 +234,20 @@ export function PublicInternetPlansContent({
const tiers: TierInfo[] = sortedTierPlans.map(plan => ({ const tiers: TierInfo[] = sortedTierPlans.map(plan => ({
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"], tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
monthlyPrice: plan.monthlyPrice ?? 0, monthlyPrice: plan.monthlyPrice ?? 0,
description: getTierDescription(plan.internetPlanTier ?? ""), description: getTierDescription(plan.internetPlanTier ?? "", true),
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""), features:
plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true),
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined, pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined,
})); }));
offerings.push({ offerings.push({
offeringType: "Apartment", offeringType: "Apartment",
title: meta.title, title: meta?.title ?? "Apartment",
speedBadge: "Up to 1Gbps", speedBadge: meta?.speedBadge ?? "Up to 1Gbps",
description: meta.description, description:
iconType: meta.iconType, meta?.description ??
"For mansions and apartment buildings. Speed depends on your building.",
iconType: meta?.iconType ?? "apartment",
startingPrice, startingPrice,
setupFee, setupFee,
tiers, tiers,
@ -323,10 +255,10 @@ export function PublicInternetPlansContent({
}); });
} }
// Sort by order // Sort by display order
return offerings.sort((a, b) => { return offerings.sort((a, b) => {
const orderA = offeringMeta[a.offeringType]?.order ?? 99; const orderA = offeringDisplayOrder[a.offeringType] ?? 99;
const orderB = offeringMeta[b.offeringType]?.order ?? 99; const orderB = offeringDisplayOrder[b.offeringType] ?? 99;
return orderA - orderB; return orderA - orderB;
}); });
}, [servicesCatalog]); }, [servicesCatalog]);
@ -378,7 +310,7 @@ export function PublicInternetPlansContent({
className="animate-in fade-in slide-in-from-bottom-8 duration-700" className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }} style={{ animationDelay: "300ms" }}
> >
<ServiceHighlights features={internetFeatures} /> <ServiceHighlights features={INTERNET_FEATURES} />
</section> </section>
{/* Connection types section */} {/* Connection types section */}
@ -450,34 +382,10 @@ export function PublicInternetPlansContent({
* Clean, polished design optimized for conversion * Clean, polished design optimized for conversion
*/ */
export function PublicInternetPlansView() { export function PublicInternetPlansView() {
return <PublicInternetPlansContent />; return (
} <PublicInternetPlansContent
ctaPath="/services/internet/check-availability"
// Helper functions ctaLabel="Check Availability"
function getSpeedBadge(offeringType: string): string { />
const speeds: Record<string, string> = { );
"Apartment 100M": "100Mbps",
"Apartment 1G": "1Gbps",
"Home 1G": "1Gbps",
"Home 10G": "10Gbps",
};
return speeds[offeringType] ?? "1Gbps";
}
function getTierDescription(tier: string): string {
const descriptions: Record<string, string> = {
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<string, string[]> = {
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] ?? [];
} }

View File

@ -1,72 +1,19 @@
"use client"; "use client";
import { import { ShieldCheck, Zap, CreditCard, Play, Globe, Package } from "lucide-react";
ShieldCheck,
Zap,
Wifi,
Router,
Globe,
Headphones,
Package,
CreditCard,
Play,
MonitorPlay,
} from "lucide-react";
import { usePublicVpnCatalog } from "@/features/services/hooks"; import { usePublicVpnCatalog } from "@/features/services/hooks";
import { VPN_FEATURES } from "@/features/services/utils";
import { LoadingCard } from "@/components/atoms"; import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard"; import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard";
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink"; import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights";
ServiceHighlights,
type HighlightFeature,
} from "@/features/services/components/base/ServiceHighlights";
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks"; import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA"; import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ"; import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
// VPN-specific features for ServiceHighlights
const vpnFeatures: HighlightFeature[] = [
{
icon: <Router className="h-6 w-6" />,
title: "Pre-configured Router",
description: "Ready to use out of the box — just plug in and connect",
highlight: "Plug & play",
},
{
icon: <Globe className="h-6 w-6" />,
title: "US & UK Servers",
description: "Access content from San Francisco or London regions",
highlight: "2 locations",
},
{
icon: <MonitorPlay className="h-6 w-6" />,
title: "Streaming Ready",
description: "Works with Apple TV, Roku, Amazon Fire, and more",
highlight: "All devices",
},
{
icon: <Wifi className="h-6 w-6" />,
title: "Separate Network",
description: "VPN runs on dedicated WiFi, keep regular internet normal",
highlight: "No interference",
},
{
icon: <Package className="h-6 w-6" />,
title: "Router Rental Included",
description: "No equipment purchase — router rental is part of the plan",
highlight: "No hidden costs",
},
{
icon: <Headphones className="h-6 w-6" />,
title: "English Support",
description: "Full English assistance for setup and troubleshooting",
highlight: "Dedicated help",
},
];
// Steps for HowItWorks // Steps for HowItWorks
const vpnSteps: HowItWorksStep[] = [ const vpnSteps: HowItWorksStep[] = [
{ {
@ -207,7 +154,7 @@ export function PublicVpnPlansView() {
className="animate-in fade-in slide-in-from-bottom-8 duration-700" className="animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "400ms" }} style={{ animationDelay: "400ms" }}
> >
<ServiceHighlights features={vpnFeatures} /> <ServiceHighlights features={VPN_FEATURES} />
</section> </section>
{/* Plans Section */} {/* Plans Section */}

View File

@ -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
```

View File

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

View File

@ -47,6 +47,34 @@ export const GENDER = {
export type GenderValue = (typeof GENDER)[keyof typeof 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) // Re-export Types from Schema (Schema-First Approach)
// ============================================================================ // ============================================================================
@ -69,6 +97,7 @@ export type {
// Token types // Token types
AuthTokens, AuthTokens,
AuthSession, AuthSession,
PasswordResetTokenPayload,
// Response types // Response types
AuthResponse, AuthResponse,
SignupResult, SignupResult,

View File

@ -17,6 +17,8 @@ export {
AUTH_ERROR_CODE, AUTH_ERROR_CODE,
TOKEN_TYPE, TOKEN_TYPE,
GENDER, GENDER,
PASSWORD_RESET_CONFIG,
OTP_CONFIG,
type AuthErrorCode, type AuthErrorCode,
type TokenTypeValue, type TokenTypeValue,
type GenderValue, type GenderValue,
@ -40,6 +42,7 @@ export type {
// Token types // Token types
AuthTokens, AuthTokens,
AuthSession, AuthSession,
PasswordResetTokenPayload,
// Response types // Response types
AuthResponse, AuthResponse,
SignupResult, SignupResult,
@ -77,6 +80,7 @@ export {
// Token schemas // Token schemas
authTokensSchema, authTokensSchema,
authSessionSchema, authSessionSchema,
passwordResetTokenPayloadSchema,
// Response schemas // Response schemas
authResponseSchema, authResponseSchema,

View File

@ -137,6 +137,21 @@ export const refreshTokenRequestSchema = z.object({
// Token Schemas // 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<typeof passwordResetTokenPayloadSchema>;
export const authTokensSchema = z.object({ export const authTokensSchema = z.object({
accessToken: z.string().min(1, "Access token is required"), accessToken: z.string().min(1, "Access token is required"),
refreshToken: z.string().min(1, "Refresh token is required"), refreshToken: z.string().min(1, "Refresh token is required"),

View File

@ -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<typeof sendVerificationCodeRequestSchema>;
export type VerifyCodeRequest = z.infer<typeof verifyCodeRequestSchema>;
export type QuickEligibilityRequest = z.infer<typeof quickEligibilityRequestSchema>;
export type CompleteAccountRequest = z.infer<typeof completeAccountRequestSchema>;
export type MaybeLaterRequest = z.infer<typeof maybeLaterRequestSchema>;
// ============================================================================
// Response Types
// ============================================================================
export type SendVerificationCodeResponse = z.infer<typeof sendVerificationCodeResponseSchema>;
export type VerifyCodeResponse = z.infer<typeof verifyCodeResponseSchema>;
export type QuickEligibilityResponse = z.infer<typeof quickEligibilityResponseSchema>;
export type MaybeLaterResponse = z.infer<typeof maybeLaterResponseSchema>;
// ============================================================================
// Session Types
// ============================================================================
export type GetStartedSession = z.infer<typeof getStartedSessionSchema>;
// ============================================================================
// Error Types
// ============================================================================
export interface GetStartedError {
code: OtpErrorCode | GetStartedErrorCode;
message: string;
}

View File

@ -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";

View File

@ -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(),
});

View File

@ -118,6 +118,10 @@
"./address/providers": { "./address/providers": {
"import": "./dist/address/providers/index.js", "import": "./dist/address/providers/index.js",
"types": "./dist/address/providers/index.d.ts" "types": "./dist/address/providers/index.d.ts"
},
"./get-started": {
"import": "./dist/get-started/index.js",
"types": "./dist/get-started/index.d.ts"
} }
}, },
"scripts": { "scripts": {

View File

@ -53,7 +53,6 @@ function baseProduct(
id: product.Id, id: product.Id,
sku, sku,
name: product.Name ?? sku, name: product.Name ?? sku,
catalogMetadata: {},
}; };
if (product.Description) base.description = product.Description; if (product.Description) base.description = product.Description;
@ -124,7 +123,6 @@ export function mapInternetInstallation(
return { return {
...base, ...base,
catalogMetadata: { catalogMetadata: {
...base.catalogMetadata,
installationTerm: inferInstallationTermFromSku(base.sku), installationTerm: inferInstallationTermFromSku(base.sku),
}, },
}; };
@ -143,7 +141,6 @@ export function mapInternetAddon(
bundledAddonId, bundledAddonId,
isBundledAddon, isBundledAddon,
catalogMetadata: { catalogMetadata: {
...base.catalogMetadata,
addonType: inferAddonTypeFromSku(base.sku), addonType: inferAddonTypeFromSku(base.sku),
}, },
}; };
@ -185,7 +182,6 @@ export function mapSimActivationFee(
return { return {
...simProduct, ...simProduct,
catalogMetadata: { catalogMetadata: {
...(simProduct.catalogMetadata ?? {}),
isDefault: false, // Will be handled by service fallback isDefault: false, // Will be handled by service fallback
}, },
}; };

View File

@ -11,6 +11,10 @@ import { addressSchema } from "../customer/index.js";
// Base Catalog Product Schema // 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({ export const catalogProductBaseSchema = z.object({
id: z.string(), id: z.string(),
sku: z.string(), sku: z.string(),
@ -21,7 +25,6 @@ export const catalogProductBaseSchema = z.object({
monthlyPrice: z.number().optional(), monthlyPrice: z.number().optional(),
oneTimePrice: z.number().optional(), oneTimePrice: z.number().optional(),
unitPrice: 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({ export const internetAddonCatalogItemSchema = internetCatalogProductSchema.extend({
isBundledAddon: z.boolean().optional(), isBundledAddon: z.boolean().optional(),
bundledAddonId: z.string().optional(), bundledAddonId: z.string().optional(),
catalogMetadata: z
.object({
addonType: z.string().optional(),
})
.optional(),
}); });
export const internetCatalogCollectionSchema = z.object({ export const internetCatalogCollectionSchema = z.object({

View File

@ -18,6 +18,7 @@
"address/**/*", "address/**/*",
"auth/**/*", "auth/**/*",
"billing/**/*", "billing/**/*",
"get-started/**/*",
"services/**/*", "services/**/*",
"checkout/**/*", "checkout/**/*",
"common/**/*", "common/**/*",