feat(address): integrate AddressModule and optimize Japan address form handling
This commit is contained in:
parent
78689da8fb
commit
fd15324ef0
@ -16,6 +16,7 @@ import { SupportModule } from "@bff/modules/support/support.module.js";
|
||||
import { RealtimeApiModule } from "@bff/modules/realtime/realtime.module.js";
|
||||
import { VerificationModule } from "@bff/modules/verification/verification.module.js";
|
||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||
import { AddressModule } from "@bff/modules/address/address.module.js";
|
||||
|
||||
export const apiRoutes: Routes = [
|
||||
{
|
||||
@ -38,6 +39,7 @@ export const apiRoutes: Routes = [
|
||||
{ path: "", module: RealtimeApiModule },
|
||||
{ path: "", module: VerificationModule },
|
||||
{ path: "", module: NotificationsModule },
|
||||
{ path: "", module: AddressModule },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -6,3 +6,4 @@ export {
|
||||
type RateLimitOptions,
|
||||
RATE_LIMIT_KEY,
|
||||
} from "./rate-limit.decorator.js";
|
||||
export { getRequestFingerprint } from "./rate-limit.util.js";
|
||||
|
||||
@ -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 {
|
||||
return parts.filter(Boolean).join(":");
|
||||
}
|
||||
|
||||
@ -38,38 +38,61 @@ export class JapanPostAddressService {
|
||||
|
||||
// Validate format
|
||||
if (!/^\d{7}$/.test(normalizedZip)) {
|
||||
this.logger.warn("Invalid ZIP code format", {
|
||||
zipCode,
|
||||
normalizedZip,
|
||||
reason: "Must be exactly 7 digits",
|
||||
});
|
||||
throw new BadRequestException("ZIP code must be 7 digits (e.g., 100-0001)");
|
||||
}
|
||||
|
||||
// Check if service is configured
|
||||
if (!this.connection.isConfigured()) {
|
||||
this.logger.error("Japan Post API not configured");
|
||||
this.logger.error("Japan Post API not configured - address lookup unavailable", {
|
||||
zipCode: normalizedZip,
|
||||
configErrors: this.connection.getConfigErrors(),
|
||||
});
|
||||
throw new ServiceUnavailableException("Address lookup service is not available");
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const rawResponse = await this.connection.searchByZipCode(normalizedZip);
|
||||
|
||||
// Use domain mapper for transformation (single transformation point)
|
||||
const result = JapanPost.transformJapanPostSearchResponse(rawResponse);
|
||||
|
||||
this.logger.log("Japan Post address lookup completed", {
|
||||
this.logger.log("Address lookup completed", {
|
||||
zipCode: normalizedZip,
|
||||
found: result.count > 0,
|
||||
count: result.count,
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Re-throw known exceptions
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Re-throw known exceptions (already logged at connection layer)
|
||||
if (error instanceof BadRequestException || error instanceof ServiceUnavailableException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error("Japan Post address lookup failed", {
|
||||
zipCode: normalizedZip,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
// Check if this is an HTTP error from connection layer (already logged there)
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
const isConnectionError = errorMessage.includes("HTTP") || errorMessage.includes("timed out");
|
||||
|
||||
if (!isConnectionError) {
|
||||
// Only log unexpected errors (e.g., transformation failures)
|
||||
this.logger.error("Address lookup failed at service layer", {
|
||||
zipCode: normalizedZip,
|
||||
durationMs,
|
||||
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
||||
error: errorMessage,
|
||||
stage: "response_transformation",
|
||||
});
|
||||
}
|
||||
|
||||
throw new ServiceUnavailableException("Failed to lookup address. Please try again.");
|
||||
}
|
||||
|
||||
@ -33,6 +33,15 @@ interface ConfigValidationError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Japan Post API error response format
|
||||
*/
|
||||
interface JapanPostErrorResponse {
|
||||
request_id?: string;
|
||||
error_code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JapanPostConnectionService implements OnModuleInit {
|
||||
private accessToken: string | null = null;
|
||||
@ -142,9 +151,11 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
const startTime = Date.now();
|
||||
const tokenUrl = `${this.config.baseUrl}/api/v1/j/token`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.baseUrl}/api/v1/j/token`, {
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@ -158,11 +169,24 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Token request failed: HTTP ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
|
||||
);
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
const parsedError = this.parseErrorResponse(errorBody);
|
||||
|
||||
this.logger.error("Japan Post token request failed", {
|
||||
endpoint: tokenUrl,
|
||||
httpStatus: response.status,
|
||||
httpStatusText: response.statusText,
|
||||
durationMs,
|
||||
// Japan Post API error details
|
||||
requestId: parsedError?.request_id,
|
||||
errorCode: parsedError?.error_code,
|
||||
apiMessage: parsedError?.message,
|
||||
hint: this.getErrorHint(response.status, parsedError?.error_code),
|
||||
});
|
||||
throw new Error(`Token request failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as JapanPostTokenResponse;
|
||||
@ -175,19 +199,98 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
this.logger.debug("Japan Post token acquired", {
|
||||
expiresIn: validated.expires_in,
|
||||
tokenType: validated.token_type,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
return token;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to acquire Japan Post access token", {
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
const durationMs = Date.now() - startTime;
|
||||
const isAborted = error instanceof Error && error.name === "AbortError";
|
||||
|
||||
if (isAborted) {
|
||||
this.logger.error("Japan Post token request timed out", {
|
||||
endpoint: tokenUrl,
|
||||
timeoutMs: this.config.timeout,
|
||||
durationMs,
|
||||
});
|
||||
throw new Error(`Token request timed out after ${this.config.timeout}ms`);
|
||||
}
|
||||
|
||||
// Only log if not already logged above (non-ok response)
|
||||
if (!(error instanceof Error && error.message.startsWith("Token request failed"))) {
|
||||
this.logger.error("Japan Post token request error", {
|
||||
endpoint: tokenUrl,
|
||||
error: extractErrorMessage(error),
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Japan Post API error response
|
||||
*/
|
||||
private parseErrorResponse(body: string): JapanPostErrorResponse | null {
|
||||
if (!body) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(body) as JapanPostErrorResponse;
|
||||
// Validate it has the expected shape
|
||||
if (parsed && typeof parsed === "object") {
|
||||
return {
|
||||
request_id: parsed.request_id,
|
||||
error_code: parsed.error_code,
|
||||
message: parsed.message,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a helpful hint based on HTTP status code and API error code
|
||||
*/
|
||||
private getErrorHint(status: number, errorCode?: string): string {
|
||||
// Handle specific Japan Post error codes (format: "400-1028-0001")
|
||||
if (errorCode) {
|
||||
if (errorCode.startsWith("400-1028")) {
|
||||
return "Invalid client_id or secret_key - check JAPAN_POST_CLIENT_ID and JAPAN_POST_CLIENT_SECRET";
|
||||
}
|
||||
if (errorCode.startsWith("401")) {
|
||||
return "Token is invalid or expired - will retry with fresh token";
|
||||
}
|
||||
if (errorCode.startsWith("404")) {
|
||||
return "ZIP code not found in Japan Post database";
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to HTTP status hints
|
||||
switch (status) {
|
||||
case 400:
|
||||
return "Invalid request - check ZIP code format (7 digits)";
|
||||
case 401:
|
||||
return "Token expired or invalid - check credentials";
|
||||
case 403:
|
||||
return "Access forbidden - API credentials may be suspended";
|
||||
case 404:
|
||||
return "ZIP code not found in Japan Post database";
|
||||
case 429:
|
||||
return "Rate limit exceeded - reduce request frequency";
|
||||
case 500:
|
||||
return "Japan Post API internal error - retry later";
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return "Japan Post API is temporarily unavailable - retry later";
|
||||
default:
|
||||
return "Unexpected error - check API configuration";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search addresses by ZIP code
|
||||
*
|
||||
@ -199,12 +302,12 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
const startTime = Date.now();
|
||||
const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`;
|
||||
|
||||
this.logger.debug("Japan Post ZIP code search started", { zipCode });
|
||||
|
||||
try {
|
||||
const url = `${this.config.baseUrl}/api/v1/searchcode/${zipCode}`;
|
||||
|
||||
this.logger.debug("Japan Post ZIP code search", { zipCode, url });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
@ -214,26 +317,59 @@ export class JapanPostConnectionService implements OnModuleInit {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`ZIP code search failed: HTTP ${response.status}${errorText ? ` - ${errorText}` : ""}`
|
||||
);
|
||||
const errorBody = await response.text().catch(() => "");
|
||||
const parsedError = this.parseErrorResponse(errorBody);
|
||||
|
||||
this.logger.error("Japan Post ZIP search request failed", {
|
||||
zipCode,
|
||||
endpoint: url,
|
||||
httpStatus: response.status,
|
||||
httpStatusText: response.statusText,
|
||||
durationMs,
|
||||
// Japan Post API error details
|
||||
requestId: parsedError?.request_id,
|
||||
errorCode: parsedError?.error_code,
|
||||
apiMessage: parsedError?.message,
|
||||
hint: this.getErrorHint(response.status, parsedError?.error_code),
|
||||
});
|
||||
throw new Error(`ZIP code search failed: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.logger.debug("Japan Post search response received", {
|
||||
this.logger.debug("Japan Post ZIP search completed", {
|
||||
zipCode,
|
||||
resultCount: (data as { count?: number }).count,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error("Japan Post ZIP code search failed", {
|
||||
zipCode,
|
||||
error: extractErrorMessage(error),
|
||||
});
|
||||
const durationMs = Date.now() - startTime;
|
||||
const isAborted = error instanceof Error && error.name === "AbortError";
|
||||
|
||||
if (isAborted) {
|
||||
this.logger.error("Japan Post ZIP search timed out", {
|
||||
zipCode,
|
||||
endpoint: url,
|
||||
timeoutMs: this.config.timeout,
|
||||
durationMs,
|
||||
});
|
||||
throw new Error(`ZIP search timed out after ${this.config.timeout}ms`);
|
||||
}
|
||||
|
||||
// Only log if not already logged above (non-ok response)
|
||||
if (!(error instanceof Error && error.message.startsWith("ZIP code search failed"))) {
|
||||
this.logger.error("Japan Post ZIP search error", {
|
||||
zipCode,
|
||||
endpoint: url,
|
||||
error: extractErrorMessage(error),
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@ -3,6 +3,7 @@ import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
|
||||
import type { WhmcsErrorResponse } from "@customer-portal/domain/common/providers";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { matchCommonError } from "@bff/core/errors/index.js";
|
||||
|
||||
/**
|
||||
* Service for handling and normalizing WHMCS API errors
|
||||
@ -27,8 +28,10 @@ export class WhmcsErrorHandlerService {
|
||||
|
||||
/**
|
||||
* Handle general request errors (network, timeout, etc.)
|
||||
* @param error - The error to handle
|
||||
* @param _context - Context string for error messages (kept for signature consistency)
|
||||
*/
|
||||
handleRequestError(error: unknown): never {
|
||||
handleRequestError(error: unknown, _context?: string): never {
|
||||
if (this.isTimeoutError(error)) {
|
||||
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
|
||||
}
|
||||
@ -37,9 +40,25 @@ export class WhmcsErrorHandlerService {
|
||||
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
|
||||
}
|
||||
|
||||
// If upstream already threw a DomainHttpException or HttpException with code,
|
||||
// let the global exception filter handle it.
|
||||
throw error;
|
||||
if (this.isRateLimitError(error)) {
|
||||
throw new DomainHttpException(
|
||||
ErrorCode.RATE_LIMITED,
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
"WHMCS rate limit exceeded"
|
||||
);
|
||||
}
|
||||
|
||||
// Re-throw if already a DomainHttpException
|
||||
if (error instanceof DomainHttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wrap unknown errors
|
||||
throw new DomainHttpException(
|
||||
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
_context ? `WHMCS ${_context} failed` : "WHMCS operation failed"
|
||||
);
|
||||
}
|
||||
|
||||
private mapProviderErrorToDomain(
|
||||
@ -164,20 +183,30 @@ export class WhmcsErrorHandlerService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a rate limit error
|
||||
*/
|
||||
private isRateLimitError(error: unknown): boolean {
|
||||
const message = extractErrorMessage(error).toLowerCase();
|
||||
return (
|
||||
message.includes("rate limit") ||
|
||||
message.includes("too many requests") ||
|
||||
message.includes("429")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for client consumption
|
||||
*/
|
||||
getUserFriendlyMessage(error: unknown): string {
|
||||
const message = extractErrorMessage(error).toLowerCase();
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
if (message.includes("timeout")) {
|
||||
return "The request timed out. Please try again.";
|
||||
// Use shared error pattern matcher with billing category
|
||||
const commonResult = matchCommonError(message, "billing");
|
||||
if (commonResult.matched) {
|
||||
return commonResult.message;
|
||||
}
|
||||
|
||||
if (message.includes("network") || message.includes("connection")) {
|
||||
return "Network error. Please check your connection and try again.";
|
||||
}
|
||||
|
||||
return "An unexpected error occurred. Please try again later.";
|
||||
return "Billing operation failed. Please try again or contact support.";
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard.js
|
||||
import { TokenBlacklistService } from "./infra/token/token-blacklist.service.js";
|
||||
import { TokenStorageService } from "./infra/token/token-storage.service.js";
|
||||
import { TokenRevocationService } from "./infra/token/token-revocation.service.js";
|
||||
import { PasswordResetTokenService } from "./infra/token/password-reset-token.service.js";
|
||||
import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||
import { AuthTokenService } from "./infra/token/token.service.js";
|
||||
@ -25,10 +26,15 @@ import { SignupAccountResolverService } from "./infra/workflows/signup/signup-ac
|
||||
import { SignupValidationService } from "./infra/workflows/signup/signup-validation.service.js";
|
||||
import { SignupWhmcsService } from "./infra/workflows/signup/signup-whmcs.service.js";
|
||||
import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-creation.service.js";
|
||||
// Get Started flow
|
||||
import { OtpService } from "./infra/otp/otp.service.js";
|
||||
import { GetStartedSessionService } from "./infra/otp/get-started-session.service.js";
|
||||
import { GetStartedWorkflowService } from "./infra/workflows/get-started-workflow.service.js";
|
||||
import { GetStartedController } from "./presentation/http/get-started.controller.js";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, MappingsModule, IntegrationsModule, EmailModule, CacheModule],
|
||||
controllers: [AuthController],
|
||||
controllers: [AuthController, GetStartedController],
|
||||
providers: [
|
||||
// Application services
|
||||
AuthFacade,
|
||||
@ -40,6 +46,7 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-
|
||||
TokenRevocationService,
|
||||
AuthTokenService,
|
||||
JoseJwtService,
|
||||
PasswordResetTokenService,
|
||||
// Signup workflow services
|
||||
SignupWorkflowService,
|
||||
SignupAccountResolverService,
|
||||
@ -49,6 +56,10 @@ import { SignupUserCreationService } from "./infra/workflows/signup/signup-user-
|
||||
// Other workflow services
|
||||
PasswordWorkflowService,
|
||||
WhmcsLinkWorkflowService,
|
||||
// Get Started flow services
|
||||
OtpService,
|
||||
GetStartedSessionService,
|
||||
GetStartedWorkflowService,
|
||||
// Guards and interceptors
|
||||
FailedLoginThrottleGuard,
|
||||
AuthRateLimitService,
|
||||
|
||||
@ -5,6 +5,12 @@ import type { UserAuth } from "@customer-portal/domain/customer";
|
||||
|
||||
export type RequestWithUser = Request & { user: User };
|
||||
|
||||
/**
|
||||
* Request with optional user - used for @OptionalAuth() endpoints
|
||||
* where no token = user is undefined, valid token = user is attached
|
||||
*/
|
||||
export type RequestWithOptionalUser = Request & { user?: User };
|
||||
|
||||
/**
|
||||
* Internal auth result used inside the BFF to set cookies.
|
||||
* Token strings must not be returned to the browser.
|
||||
|
||||
@ -11,3 +11,18 @@ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
*/
|
||||
export const IS_PUBLIC_NO_SESSION_KEY = "isPublicNoSession";
|
||||
export const PublicNoSession = () => SetMetadata(IS_PUBLIC_NO_SESSION_KEY, true);
|
||||
|
||||
/**
|
||||
* Marks a route as optionally authenticated.
|
||||
*
|
||||
* Behavior:
|
||||
* - No token: allow request, user = null (200 response, controller handles null user)
|
||||
* - Valid token: allow request, user attached (normal authenticated flow)
|
||||
* - Invalid/expired token: return 401 (signals "session expired" to client)
|
||||
*
|
||||
* Use this for "check if I'm logged in" endpoints like /me where:
|
||||
* - Unauthenticated users should get a graceful response (not 401)
|
||||
* - Users with expired sessions SHOULD get 401 (to trigger re-login)
|
||||
*/
|
||||
export const IS_OPTIONAL_AUTH_KEY = "isOptionalAuth";
|
||||
export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH_KEY, true);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
2
apps/bff/src/modules/auth/infra/otp/index.ts
Normal file
2
apps/bff/src/modules/auth/infra/otp/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { OtpService, type OtpVerifyResult } from "./otp.service.js";
|
||||
export { GetStartedSessionService } from "./get-started-session.service.js";
|
||||
208
apps/bff/src/modules/auth/infra/otp/otp.service.ts
Normal file
208
apps/bff/src/modules/auth/infra/otp/otp.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { AuthTokenService } from "../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js";
|
||||
import { JoseJwtService } from "../token/jose-jwt.service.js";
|
||||
import { PasswordResetTokenService } from "../token/password-reset-token.service.js";
|
||||
import {
|
||||
type ChangePasswordRequest,
|
||||
changePasswordRequestSchema,
|
||||
@ -31,7 +31,7 @@ export class PasswordWorkflowService {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly jwtService: JoseJwtService,
|
||||
private readonly passwordResetTokenService: PasswordResetTokenService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
private readonly authRateLimitService: AuthRateLimitService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@ -113,10 +113,12 @@ export class PasswordWorkflowService {
|
||||
}
|
||||
const user = await this.usersFacade.findByEmailInternal(email);
|
||||
if (!user) {
|
||||
// Don't reveal whether user exists
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await this.jwtService.sign({ sub: user.id, purpose: "password_reset" }, "15m");
|
||||
// Create single-use password reset token
|
||||
const token = await this.passwordResetTokenService.create(user.id);
|
||||
|
||||
const appBase = this.configService.get<string>("APP_BASE_URL", "http://localhost:3000");
|
||||
const resetUrl = `${appBase}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
@ -143,14 +145,13 @@ export class PasswordWorkflowService {
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string): Promise<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(
|
||||
async () => {
|
||||
const payload = await this.jwtService.verify<{ sub: string; purpose: string }>(token);
|
||||
if (payload.purpose !== "password_reset") {
|
||||
throw new BadRequestException("Invalid token");
|
||||
}
|
||||
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(payload.sub);
|
||||
const prismaUser = await this.usersFacade.findByIdInternal(userId);
|
||||
if (!prismaUser) throw new BadRequestException("Invalid token");
|
||||
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
@ -166,7 +167,7 @@ export class PasswordWorkflowService {
|
||||
this.logger,
|
||||
{
|
||||
context: "Reset password",
|
||||
fallbackMessage: "Invalid or expired token",
|
||||
fallbackMessage: "Failed to reset password",
|
||||
rethrow: [BadRequestException],
|
||||
}
|
||||
);
|
||||
|
||||
@ -223,9 +223,11 @@ export class SignupValidationService {
|
||||
try {
|
||||
const existingSf = await this.salesforceAccountService.findByEmail(normalizedEmail);
|
||||
if (existingSf) {
|
||||
result.nextAction = "blocked";
|
||||
// SF account exists without WHMCS - allow account completion via get-started flow
|
||||
result.salesforce.accountId = existingSf.id;
|
||||
result.nextAction = "complete_account";
|
||||
result.messages.push(
|
||||
"We found an existing customer record for this email. Please transfer your account or contact support."
|
||||
"We found your existing account. Please verify your email to complete setup."
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -24,7 +24,14 @@ export interface SignupAccountCacheEntry {
|
||||
*/
|
||||
export interface SignupPreflightResult {
|
||||
canProceed: boolean;
|
||||
nextAction: "login" | "proceed_signup" | "link_whmcs" | "fix_input" | "blocked" | null;
|
||||
nextAction:
|
||||
| "login"
|
||||
| "proceed_signup"
|
||||
| "link_whmcs"
|
||||
| "fix_input"
|
||||
| "blocked"
|
||||
| "complete_account"
|
||||
| null;
|
||||
messages: string[];
|
||||
normalized: {
|
||||
email: string;
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
type RequestWithRateLimit,
|
||||
} from "./guards/failed-login-throttle.guard.js";
|
||||
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
|
||||
import { Public } from "../../decorators/public.decorator.js";
|
||||
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
@ -301,8 +301,18 @@ export class AuthController {
|
||||
return { user: result.user, session: this.toSession(result.tokens) };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /auth/me - Check authentication status
|
||||
*
|
||||
* Uses @OptionalAuth: returns isAuthenticated: false if not logged in,
|
||||
* 401 only if session cookie is present but expired/invalid
|
||||
*/
|
||||
@OptionalAuth()
|
||||
@Get("me")
|
||||
getAuthStatus(@Req() req: Request & { user: UserAuth }) {
|
||||
getAuthStatus(@Req() req: Request & { user?: UserAuth }) {
|
||||
if (!req.user) {
|
||||
return { isAuthenticated: false };
|
||||
}
|
||||
return { isAuthenticated: true, user: req.user };
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,11 @@ import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
|
||||
import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service.js";
|
||||
import { IS_PUBLIC_KEY, IS_PUBLIC_NO_SESSION_KEY } from "../../../decorators/public.decorator.js";
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
IS_PUBLIC_NO_SESSION_KEY,
|
||||
IS_OPTIONAL_AUTH_KEY,
|
||||
} from "../../../decorators/public.decorator.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { JoseJwtService } from "../../../infra/token/jose-jwt.service.js";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
@ -70,6 +74,33 @@ export class GlobalAuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the route is marked as optionally authenticated
|
||||
// OptionalAuth: no token = allow (user=null), invalid token = 401
|
||||
const isOptionalAuth = this.reflector.getAllAndOverride<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 {
|
||||
const token = extractAccessTokenFromRequest(request);
|
||||
if (!token) {
|
||||
|
||||
@ -16,8 +16,9 @@ import { addressSchema, userSchema } from "@customer-portal/domain/customer";
|
||||
import { bilingualAddressSchema } from "@customer-portal/domain/address";
|
||||
import type { Address } from "@customer-portal/domain/customer";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
import type { RequestWithUser, RequestWithOptionalUser } from "@bff/modules/auth/auth.types.js";
|
||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||
import { OptionalAuth } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||
|
||||
class UpdateAddressDto extends createZodDto(addressSchema.partial()) {}
|
||||
class UpdateBilingualAddressDto extends createZodDto(bilingualAddressSchema) {}
|
||||
@ -34,12 +35,17 @@ export class UsersController {
|
||||
/**
|
||||
* GET /me - Get complete customer profile (includes address)
|
||||
* Profile data fetched from WHMCS (single source of truth)
|
||||
*
|
||||
* Uses @OptionalAuth: returns null if not logged in, 401 only if session expired
|
||||
*/
|
||||
@OptionalAuth()
|
||||
@UseGuards(SalesforceReadThrottleGuard)
|
||||
@Get()
|
||||
@ZodResponse({ description: "Get user profile", type: UserDto })
|
||||
async getProfile(@Req() req: RequestWithUser): Promise<User> {
|
||||
// This endpoint represents the authenticated user; treat missing user as an error.
|
||||
@ZodSerializerDto(userSchema.nullable())
|
||||
async getProfile(@Req() req: RequestWithOptionalUser): Promise<User | null> {
|
||||
if (!req.user) {
|
||||
return null;
|
||||
}
|
||||
return this.usersFacade.getProfile(req.user.id);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { GetStartedView } from "@/features/get-started";
|
||||
|
||||
export default function GetStartedPage() {
|
||||
return <GetStartedView />;
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import MigrateAccountView from "@/features/auth/views/MigrateAccountView";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function MigrateAccountPage() {
|
||||
return <MigrateAccountView />;
|
||||
redirect("/auth/get-started");
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import SignupView from "@/features/auth/views/SignupView";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SignupPage() {
|
||||
return <SignupView />;
|
||||
redirect("/auth/get-started");
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
* Japanese fields are stored separately for Salesforce sync.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { JapanAddressForm, type JapanAddressFormData } from "./JapanAddressForm";
|
||||
import {
|
||||
type BilingualAddress,
|
||||
@ -106,8 +106,14 @@ function fromLegacyFormat(address: LegacyAddressData): Partial<JapanAddressFormD
|
||||
}
|
||||
}
|
||||
|
||||
// Determine residence type based on whether we have a room number
|
||||
const residenceType = roomNumber ? RESIDENCE_TYPE.APARTMENT : RESIDENCE_TYPE.HOUSE;
|
||||
// Only set residence type if there's existing address data
|
||||
// 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 {
|
||||
postcode: address.postcode || "",
|
||||
@ -132,20 +138,26 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
|
||||
const { values, errors, touched, setValue, setTouchedField } = form;
|
||||
const address = values.address;
|
||||
|
||||
// Track Japan address form data separately
|
||||
const [japanData, setJapanData] = useState<JapanAddressFormData>(() => ({
|
||||
postcode: address.postcode || "",
|
||||
prefecture: address.state || "",
|
||||
city: address.city || "",
|
||||
town: address.address2 || "",
|
||||
buildingName: "",
|
||||
roomNumber: "",
|
||||
residenceType: RESIDENCE_TYPE.APARTMENT,
|
||||
prefectureJa: "",
|
||||
cityJa: "",
|
||||
townJa: "",
|
||||
...fromLegacyFormat(address),
|
||||
}));
|
||||
// Track if this is the first render to avoid infinite loops
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Compute initial values only once on mount
|
||||
const initialValues = useMemo(() => {
|
||||
return {
|
||||
postcode: address.postcode || "",
|
||||
prefecture: address.state || "",
|
||||
city: address.city || "",
|
||||
town: address.address2 || "",
|
||||
buildingName: "",
|
||||
roomNumber: "",
|
||||
residenceType: undefined,
|
||||
prefectureJa: "",
|
||||
cityJa: "",
|
||||
townJa: "",
|
||||
...fromLegacyFormat(address),
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only compute on mount
|
||||
|
||||
// Extract address field errors
|
||||
const getError = (field: string): string | undefined => {
|
||||
@ -176,7 +188,11 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
|
||||
// Handle Japan address form changes
|
||||
const handleJapanAddressChange = useCallback(
|
||||
(data: JapanAddressFormData, _isComplete: boolean) => {
|
||||
setJapanData(data);
|
||||
// Skip the initial mount to avoid loops
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to legacy format and update parent form
|
||||
const legacyAddress = toWhmcsFormat(data);
|
||||
@ -214,11 +230,12 @@ export function AddressStepJapan({ form, onJapaneseAddressChange }: AddressStepJ
|
||||
if (!address.country) {
|
||||
setValue("address", { ...address, country: "JP", countryCode: "JP" });
|
||||
}
|
||||
}, [address, setValue]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Only on mount
|
||||
|
||||
return (
|
||||
<JapanAddressForm
|
||||
initialValues={japanData}
|
||||
initialValues={initialValues}
|
||||
onChange={handleJapanAddressChange}
|
||||
errors={japanFieldErrors}
|
||||
touched={japanFieldTouched}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
* - 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 { Input } from "@/components/atoms";
|
||||
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)
|
||||
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
|
||||
useEffect(() => {
|
||||
if (initialValues) {
|
||||
@ -114,157 +118,117 @@ export function JapanAddressForm({
|
||||
return touched[field] ? errors[field] : undefined;
|
||||
};
|
||||
|
||||
// Notify parent of address changes with completeness check
|
||||
const notifyChange = useCallback(
|
||||
(next: InternalFormState, verified: boolean) => {
|
||||
const hasResidenceType =
|
||||
next.residenceType === RESIDENCE_TYPE.HOUSE ||
|
||||
next.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
// Notify parent of address changes via useEffect (avoids setState during render)
|
||||
useEffect(() => {
|
||||
const hasResidenceType =
|
||||
address.residenceType === RESIDENCE_TYPE.HOUSE ||
|
||||
address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
|
||||
const baseFieldsFilled =
|
||||
next.postcode.trim() !== "" &&
|
||||
next.prefecture.trim() !== "" &&
|
||||
next.city.trim() !== "" &&
|
||||
next.town.trim() !== "";
|
||||
const baseFieldsFilled =
|
||||
address.postcode.trim() !== "" &&
|
||||
address.prefecture.trim() !== "" &&
|
||||
address.city.trim() !== "" &&
|
||||
address.town.trim() !== "";
|
||||
|
||||
// Room number is required for apartments
|
||||
const roomNumberOk =
|
||||
next.residenceType !== RESIDENCE_TYPE.APARTMENT || (next.roomNumber?.trim() ?? "") !== "";
|
||||
// Room number is required for apartments
|
||||
const roomNumberOk =
|
||||
address.residenceType !== RESIDENCE_TYPE.APARTMENT ||
|
||||
(address.roomNumber?.trim() ?? "") !== "";
|
||||
|
||||
// Must have verified address from ZIP lookup
|
||||
const isComplete = verified && hasResidenceType && baseFieldsFilled && roomNumberOk;
|
||||
// Must have verified address from ZIP lookup
|
||||
const isComplete = isAddressVerified && hasResidenceType && baseFieldsFilled && roomNumberOk;
|
||||
|
||||
onChange?.(next as JapanAddressFormData, isComplete);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
// Use ref to avoid infinite loops when onChange changes reference
|
||||
onChangeRef.current?.(address as JapanAddressFormData, isComplete);
|
||||
}, [address, isAddressVerified]);
|
||||
|
||||
// Handle ZIP code change - reset verification when ZIP changes
|
||||
const handleZipChange = useCallback(
|
||||
(value: string) => {
|
||||
const normalizedNew = value.replace(/-/g, "");
|
||||
const normalizedVerified = verifiedZipCode.replace(/-/g, "");
|
||||
const shouldReset = normalizedNew !== normalizedVerified;
|
||||
|
||||
setAddress(prev => {
|
||||
// If ZIP code changed from verified one, reset address fields
|
||||
if (normalizedNew !== normalizedVerified) {
|
||||
const next: InternalFormState = {
|
||||
...prev,
|
||||
postcode: value,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
// Keep user-entered fields
|
||||
buildingName: prev.buildingName,
|
||||
roomNumber: prev.roomNumber,
|
||||
residenceType: prev.residenceType,
|
||||
};
|
||||
setIsAddressVerified(false);
|
||||
notifyChange(next, false);
|
||||
return next;
|
||||
}
|
||||
|
||||
if (shouldReset) {
|
||||
// Reset address fields when ZIP changes
|
||||
setIsAddressVerified(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
postcode: value,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
// Keep user-entered fields
|
||||
buildingName: prev.buildingName,
|
||||
roomNumber: prev.roomNumber,
|
||||
residenceType: prev.residenceType,
|
||||
}));
|
||||
} else {
|
||||
// Just update postcode formatting
|
||||
const next = { ...prev, postcode: value };
|
||||
notifyChange(next, isAddressVerified);
|
||||
return next;
|
||||
});
|
||||
setAddress(prev => ({ ...prev, postcode: value }));
|
||||
}
|
||||
},
|
||||
[verifiedZipCode, isAddressVerified, notifyChange]
|
||||
[verifiedZipCode]
|
||||
);
|
||||
|
||||
// Handle address found from ZIP lookup
|
||||
const handleAddressFound = useCallback(
|
||||
(found: JapanPostAddress) => {
|
||||
setAddress(prev => {
|
||||
const next: InternalFormState = {
|
||||
...prev,
|
||||
// English (romanized) fields - for WHMCS
|
||||
prefecture: found.prefectureRoma,
|
||||
city: found.cityRoma,
|
||||
town: found.townRoma,
|
||||
// Japanese fields - for Salesforce
|
||||
prefectureJa: found.prefecture,
|
||||
cityJa: found.city,
|
||||
townJa: found.town,
|
||||
};
|
||||
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(prev.postcode);
|
||||
notifyChange(next, true);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[notifyChange]
|
||||
);
|
||||
const handleAddressFound = useCallback((found: JapanPostAddress) => {
|
||||
setAddress(prev => {
|
||||
setIsAddressVerified(true);
|
||||
setVerifiedZipCode(prev.postcode);
|
||||
return {
|
||||
...prev,
|
||||
// English (romanized) fields - for WHMCS
|
||||
prefecture: found.prefectureRoma,
|
||||
city: found.cityRoma,
|
||||
town: found.townRoma,
|
||||
// Japanese fields - for Salesforce
|
||||
prefectureJa: found.prefecture,
|
||||
cityJa: found.city,
|
||||
townJa: found.town,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle lookup completion (success or failure)
|
||||
const handleLookupComplete = useCallback(
|
||||
(found: boolean) => {
|
||||
if (!found) {
|
||||
// Clear address fields on failed lookup
|
||||
setAddress(prev => {
|
||||
const next: InternalFormState = {
|
||||
...prev,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
};
|
||||
setIsAddressVerified(false);
|
||||
notifyChange(next, false);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[notifyChange]
|
||||
);
|
||||
const handleLookupComplete = useCallback((found: boolean) => {
|
||||
if (!found) {
|
||||
// Clear address fields on failed lookup
|
||||
setIsAddressVerified(false);
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
prefecture: "",
|
||||
prefectureJa: "",
|
||||
city: "",
|
||||
cityJa: "",
|
||||
town: "",
|
||||
townJa: "",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle residence type change
|
||||
const handleResidenceTypeChange = useCallback(
|
||||
(type: ResidenceType) => {
|
||||
setAddress(prev => {
|
||||
const next: InternalFormState = {
|
||||
...prev,
|
||||
residenceType: type,
|
||||
// Clear room number when switching to house
|
||||
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
|
||||
};
|
||||
notifyChange(next, isAddressVerified);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isAddressVerified, notifyChange]
|
||||
);
|
||||
const handleResidenceTypeChange = useCallback((type: ResidenceType) => {
|
||||
setAddress(prev => ({
|
||||
...prev,
|
||||
residenceType: type,
|
||||
// Clear room number when switching to house
|
||||
roomNumber: type === RESIDENCE_TYPE.HOUSE ? "" : prev.roomNumber,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Handle building name change
|
||||
const handleBuildingNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setAddress(prev => {
|
||||
const next = { ...prev, buildingName: value };
|
||||
notifyChange(next, isAddressVerified);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isAddressVerified, notifyChange]
|
||||
);
|
||||
const handleBuildingNameChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, buildingName: value }));
|
||||
}, []);
|
||||
|
||||
// Handle room number change
|
||||
const handleRoomNumberChange = useCallback(
|
||||
(value: string) => {
|
||||
setAddress(prev => {
|
||||
const next = { ...prev, roomNumber: value };
|
||||
notifyChange(next, isAddressVerified);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isAddressVerified, notifyChange]
|
||||
);
|
||||
const handleRoomNumberChange = useCallback((value: string) => {
|
||||
setAddress(prev => ({ ...prev, roomNumber: value }));
|
||||
}, []);
|
||||
|
||||
const isApartment = address.residenceType === RESIDENCE_TYPE.APARTMENT;
|
||||
const hasResidenceTypeSelected =
|
||||
|
||||
85
apps/portal/src/features/get-started/api/get-started.api.ts
Normal file
85
apps/portal/src/features/get-started/api/get-started.api.ts
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { GetStartedForm } from "./GetStartedForm";
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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't see it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export { EmailStep } from "./EmailStep";
|
||||
export { VerificationStep } from "./VerificationStep";
|
||||
export { AccountStatusStep } from "./AccountStatusStep";
|
||||
export { CompleteAccountStep } from "./CompleteAccountStep";
|
||||
export { SuccessStep } from "./SuccessStep";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { OtpInput } from "./OtpInput";
|
||||
2
apps/portal/src/features/get-started/components/index.ts
Normal file
2
apps/portal/src/features/get-started/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { GetStartedForm } from "./GetStartedForm";
|
||||
export { OtpInput } from "./OtpInput";
|
||||
25
apps/portal/src/features/get-started/index.ts
Normal file
25
apps/portal/src/features/get-started/index.ts
Normal 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";
|
||||
274
apps/portal/src/features/get-started/stores/get-started.store.ts
Normal file
274
apps/portal/src/features/get-started/stores/get-started.store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
@ -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;
|
||||
1
apps/portal/src/features/get-started/views/index.ts
Normal file
1
apps/portal/src/features/get-started/views/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { GetStartedView } from "./GetStartedView";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -6,74 +6,14 @@ import { ArrowRight, Check, ShieldCheck } from "lucide-react";
|
||||
import type { VpnCatalogProduct } from "@customer-portal/domain/services";
|
||||
import { CardPricing } from "@/features/services/components/base/CardPricing";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { getVpnRegionConfig } from "@/features/services/utils";
|
||||
|
||||
interface VpnPlanCardProps {
|
||||
plan: VpnCatalogProduct;
|
||||
}
|
||||
|
||||
// Region-specific data
|
||||
const regionData: Record<
|
||||
string,
|
||||
{
|
||||
flag: string;
|
||||
flagAlt: string;
|
||||
location: string;
|
||||
features: string[];
|
||||
accent: string;
|
||||
}
|
||||
> = {
|
||||
"San Francisco": {
|
||||
flag: "🇺🇸",
|
||||
flagAlt: "US Flag",
|
||||
location: "United States",
|
||||
features: [
|
||||
"Access US streaming content",
|
||||
"Optimized for Netflix, Hulu, HBO",
|
||||
"West Coast US server",
|
||||
"Low latency for streaming",
|
||||
],
|
||||
accent: "blue",
|
||||
},
|
||||
London: {
|
||||
flag: "🇬🇧",
|
||||
flagAlt: "UK Flag",
|
||||
location: "United Kingdom",
|
||||
features: [
|
||||
"Access UK streaming content",
|
||||
"Optimized for BBC iPlayer, ITV",
|
||||
"London-based server",
|
||||
"European content access",
|
||||
],
|
||||
accent: "red",
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback for unknown regions
|
||||
const defaultRegionData = {
|
||||
flag: "🌐",
|
||||
flagAlt: "Globe",
|
||||
location: "International",
|
||||
features: [
|
||||
"Secure VPN connection",
|
||||
"Pre-configured router",
|
||||
"Easy plug & play setup",
|
||||
"English support included",
|
||||
],
|
||||
accent: "primary",
|
||||
};
|
||||
|
||||
function getRegionData(planName: string) {
|
||||
// Try to match known regions
|
||||
for (const [region, data] of Object.entries(regionData)) {
|
||||
if (planName.toLowerCase().includes(region.toLowerCase())) {
|
||||
return { region, ...data };
|
||||
}
|
||||
}
|
||||
return { region: planName, ...defaultRegionData };
|
||||
}
|
||||
|
||||
export function VpnPlanCard({ plan }: VpnPlanCardProps) {
|
||||
const region = getRegionData(plan.name);
|
||||
const region = getVpnRegionConfig(plan.name);
|
||||
const isUS = region.accent === "blue";
|
||||
const isUK = region.accent === "red";
|
||||
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
export * from "./services.utils";
|
||||
export * from "./pricing";
|
||||
export * from "./internet-config";
|
||||
export * from "./service-features";
|
||||
|
||||
339
apps/portal/src/features/services/utils/internet-config.ts
Normal file
339
apps/portal/src/features/services/utils/internet-config.ts
Normal 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);
|
||||
}
|
||||
197
apps/portal/src/features/services/utils/service-features.tsx
Normal file
197
apps/portal/src/features/services/utils/service-features.tsx
Normal 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 };
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Server, CheckCircle, Clock, TriangleAlert, MapPin } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
import { useAccountInternetCatalog } from "@/features/services/hooks";
|
||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import type {
|
||||
@ -12,7 +12,6 @@ import type {
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import { InternetImportantNotes } from "@/features/services/components/internet/InternetImportantNotes";
|
||||
@ -22,103 +21,37 @@ import {
|
||||
} from "@/features/services/components/internet/InternetOfferingCard";
|
||||
import { PublicInternetPlansContent } from "@/features/services/views/PublicInternetPlans";
|
||||
import { PlanComparisonGuide } from "@/features/services/components/internet/PlanComparisonGuide";
|
||||
import { EligibilityStatusBadge } from "@/features/services/components/internet/EligibilityStatusBadge";
|
||||
import { InternetPendingState } from "@/features/services/components/internet/InternetPendingState";
|
||||
import { InternetIneligibleState } from "@/features/services/components/internet/InternetIneligibleState";
|
||||
import { useInternetEligibility } from "@/features/services/hooks";
|
||||
import { useAuthSession, useAuthStore } from "@/features/auth/stores/auth.store";
|
||||
import { cn, formatIsoDate } from "@/shared/utils";
|
||||
import {
|
||||
TIER_ORDER,
|
||||
TIER_CONFIGS,
|
||||
formatEligibilityDisplay,
|
||||
getAvailableOfferings,
|
||||
} from "@/features/services/utils";
|
||||
|
||||
// Offering configuration for display
|
||||
interface OfferingConfig {
|
||||
offeringType: string;
|
||||
title: string;
|
||||
speedBadge: string;
|
||||
description: string;
|
||||
iconType: "home" | "apartment";
|
||||
isPremium: boolean;
|
||||
displayOrder: number;
|
||||
isAlternative?: boolean;
|
||||
alternativeNote?: string;
|
||||
}
|
||||
|
||||
const OFFERING_CONFIGS: Record<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,
|
||||
},
|
||||
};
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getTierInfo(plans: InternetPlanCatalogItem[], offeringType: string): TierInfo[] {
|
||||
const filtered = plans.filter(p => p.internetOfferingType === offeringType);
|
||||
const tierOrder: ("Silver" | "Gold" | "Platinum")[] = ["Silver", "Gold", "Platinum"];
|
||||
|
||||
const tierDescriptions: Record<
|
||||
string,
|
||||
{ description: string; features: string[]; pricingNote?: string }
|
||||
> = {
|
||||
Silver: {
|
||||
description: "Essential setup—bring your own router",
|
||||
features: ["NTT modem + ISP connection", "IPoE or PPPoE protocols", "Self-configuration"],
|
||||
},
|
||||
Gold: {
|
||||
description: "All-inclusive with router rental",
|
||||
features: [
|
||||
"Everything in Silver",
|
||||
"WiFi router included",
|
||||
"Auto-configured",
|
||||
"Range extender option",
|
||||
],
|
||||
},
|
||||
Platinum: {
|
||||
description: "Tailored setup for larger homes",
|
||||
features: [
|
||||
"Netgear INSIGHT mesh routers",
|
||||
"Cloud-managed WiFi",
|
||||
"Remote support",
|
||||
"Custom setup",
|
||||
],
|
||||
pricingNote: "+ equipment fees",
|
||||
},
|
||||
};
|
||||
|
||||
const result: TierInfo[] = [];
|
||||
for (const tier of tierOrder) {
|
||||
for (const tier of TIER_ORDER) {
|
||||
const plan = filtered.find(p => p.internetPlanTier?.toLowerCase() === tier.toLowerCase());
|
||||
if (!plan) continue;
|
||||
const config = tierDescriptions[tier];
|
||||
const config = TIER_CONFIGS[tier];
|
||||
result.push({
|
||||
tier,
|
||||
planSku: plan.sku,
|
||||
monthlyPrice: plan.monthlyPrice ?? 0,
|
||||
description: config.description,
|
||||
features: config.features,
|
||||
recommended: tier === "Gold",
|
||||
recommended: config.isRecommended ?? false,
|
||||
pricingNote: config.pricingNote,
|
||||
});
|
||||
}
|
||||
@ -130,177 +63,186 @@ function getSetupFee(installations: InternetInstallationCatalogItem[]): number {
|
||||
return basic?.oneTimePrice ?? 22800;
|
||||
}
|
||||
|
||||
function getAvailableOfferings(
|
||||
eligibility: string | null,
|
||||
plans: InternetPlanCatalogItem[]
|
||||
): OfferingConfig[] {
|
||||
if (!eligibility) return [];
|
||||
|
||||
const results: OfferingConfig[] = [];
|
||||
const eligibilityLower = eligibility.toLowerCase();
|
||||
|
||||
if (eligibilityLower.includes("home 10g")) {
|
||||
const config10g = OFFERING_CONFIGS["Home 10G"];
|
||||
const config1g = OFFERING_CONFIGS["Home 1G"];
|
||||
if (config10g && plans.some(p => p.internetOfferingType === "Home 10G")) {
|
||||
results.push({ offeringType: "Home 10G", ...config10g });
|
||||
}
|
||||
if (config1g && plans.some(p => p.internetOfferingType === "Home 1G")) {
|
||||
results.push({
|
||||
offeringType: "Home 1G",
|
||||
...config1g,
|
||||
isAlternative: true,
|
||||
alternativeNote: "Lower monthly cost option",
|
||||
});
|
||||
}
|
||||
} else if (eligibilityLower.includes("home 1g")) {
|
||||
const config = OFFERING_CONFIGS["Home 1G"];
|
||||
if (config && plans.some(p => p.internetOfferingType === "Home 1G")) {
|
||||
results.push({ offeringType: "Home 1G", ...config });
|
||||
}
|
||||
} else if (eligibilityLower.includes("apartment 1g")) {
|
||||
const config = OFFERING_CONFIGS["Apartment 1G"];
|
||||
if (config && plans.some(p => p.internetOfferingType === "Apartment 1G")) {
|
||||
results.push({ offeringType: "Apartment 1G", ...config });
|
||||
}
|
||||
} else if (eligibilityLower.includes("apartment 100m")) {
|
||||
const config = OFFERING_CONFIGS["Apartment 100M"];
|
||||
if (config && plans.some(p => p.internetOfferingType === "Apartment 100M")) {
|
||||
results.push({ offeringType: "Apartment 100M", ...config });
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.displayOrder - b.displayOrder);
|
||||
}
|
||||
|
||||
function formatEligibilityDisplay(eligibility: string): {
|
||||
residenceType: "home" | "apartment";
|
||||
speed: string;
|
||||
label: string;
|
||||
description: string;
|
||||
} {
|
||||
const lower = eligibility.toLowerCase();
|
||||
|
||||
if (lower.includes("home 10g")) {
|
||||
return {
|
||||
residenceType: "home",
|
||||
speed: "10 Gbps",
|
||||
label: "Standalone House (10Gbps available)",
|
||||
description:
|
||||
"Your address supports our fastest 10Gbps service. You can also choose 1Gbps for lower monthly cost.",
|
||||
};
|
||||
}
|
||||
if (lower.includes("home 1g")) {
|
||||
return {
|
||||
residenceType: "home",
|
||||
speed: "1 Gbps",
|
||||
label: "Standalone House (1Gbps)",
|
||||
description: "Your address supports high-speed 1Gbps fiber connection.",
|
||||
};
|
||||
}
|
||||
if (lower.includes("apartment 1g")) {
|
||||
return {
|
||||
residenceType: "apartment",
|
||||
speed: "1 Gbps",
|
||||
label: "Apartment/Mansion (1Gbps FTTH)",
|
||||
description: "Your building has fiber-to-the-unit infrastructure supporting 1Gbps speeds.",
|
||||
};
|
||||
}
|
||||
if (lower.includes("apartment 100m")) {
|
||||
return {
|
||||
residenceType: "apartment",
|
||||
speed: "100 Mbps",
|
||||
label: "Apartment/Mansion (100Mbps)",
|
||||
description: "Your building uses VDSL or LAN infrastructure with up to 100Mbps speeds.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
residenceType: "home",
|
||||
speed: eligibility,
|
||||
label: eligibility,
|
||||
description: "Service is available at your address.",
|
||||
};
|
||||
}
|
||||
|
||||
// Status badge component
|
||||
function EligibilityStatusBadge({
|
||||
status,
|
||||
speed,
|
||||
}: {
|
||||
status: "eligible" | "pending" | "not_requested" | "ineligible";
|
||||
speed?: string;
|
||||
}) {
|
||||
const configs = {
|
||||
eligible: {
|
||||
icon: CheckCircle,
|
||||
bg: "bg-success-soft",
|
||||
border: "border-success/30",
|
||||
text: "text-success",
|
||||
label: "Service Available",
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
bg: "bg-info-soft",
|
||||
border: "border-info/30",
|
||||
text: "text-info",
|
||||
label: "Review in Progress",
|
||||
},
|
||||
not_requested: {
|
||||
icon: MapPin,
|
||||
bg: "bg-muted",
|
||||
border: "border-border",
|
||||
text: "text-muted-foreground",
|
||||
label: "Verification Required",
|
||||
},
|
||||
ineligible: {
|
||||
icon: TriangleAlert,
|
||||
bg: "bg-warning/10",
|
||||
border: "border-warning/30",
|
||||
text: "text-warning",
|
||||
label: "Not Available",
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[status];
|
||||
const Icon = config.icon;
|
||||
// ============================================================================
|
||||
// Loading Skeleton Component
|
||||
// ============================================================================
|
||||
|
||||
function InternetPlansLoadingSkeleton({ servicesBasePath }: { servicesBasePath: string }) {
|
||||
return (
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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'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() {
|
||||
const router = useRouter();
|
||||
const servicesBasePath = useServicesBasePath();
|
||||
const searchParams = useSearchParams();
|
||||
const { user } = useAuthSession();
|
||||
const hasCheckedAuth = useAuthStore(state => state.hasCheckedAuth);
|
||||
|
||||
// Data fetching
|
||||
const { data, error } = useAccountInternetCatalog();
|
||||
// Simple loading check: show skeleton until we have data or an error
|
||||
const isLoading = !data && !error;
|
||||
const eligibilityQuery = useInternetEligibility();
|
||||
const eligibilityLoading = eligibilityQuery.isLoading;
|
||||
const plans: InternetPlanCatalogItem[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const installations: InternetInstallationCatalogItem[] = useMemo(
|
||||
() => data?.installations ?? [],
|
||||
[data?.installations]
|
||||
);
|
||||
|
||||
// Memoized data
|
||||
const plans = useMemo(() => data?.plans ?? [], [data?.plans]);
|
||||
const installations = useMemo(() => data?.installations ?? [], [data?.installations]);
|
||||
|
||||
// Check for active subscriptions
|
||||
const { data: activeSubs } = useActiveSubscriptions();
|
||||
const hasActiveInternet = useMemo(
|
||||
() =>
|
||||
@ -316,6 +258,7 @@ export function InternetPlansContainer() {
|
||||
[activeSubs]
|
||||
);
|
||||
|
||||
// Eligibility state
|
||||
const eligibilityValue = eligibilityQuery.data?.eligibility;
|
||||
const eligibilityStatus = eligibilityQuery.data?.status;
|
||||
const requestedAt = eligibilityQuery.data?.requestedAt;
|
||||
@ -329,9 +272,11 @@ export function InternetPlansContainer() {
|
||||
const isNotRequested = eligibilityStatus === "not_requested";
|
||||
const isIneligible = eligibilityStatus === "ineligible";
|
||||
|
||||
// URL params for auto-redirect
|
||||
const autoEligibilityRequest = searchParams?.get("autoEligibilityRequest") === "1";
|
||||
const autoPlanSku = searchParams?.get("planSku");
|
||||
|
||||
// Computed values
|
||||
const eligibility = useMemo(() => {
|
||||
if (!isEligible) return null;
|
||||
return eligibilityValue?.trim() ?? null;
|
||||
@ -365,18 +310,15 @@ export function InternetPlansContainer() {
|
||||
.filter(card => card.tiers.length > 0);
|
||||
}, [availableOfferings, plans, setupFee, servicesBasePath]);
|
||||
|
||||
// Logic to handle check availability click
|
||||
const handleCheckAvailability = async (e?: React.MouseEvent) => {
|
||||
// Handlers
|
||||
const handleCheckAvailability = (e?: React.MouseEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
const target = `${servicesBasePath}/internet/request`;
|
||||
router.push(target);
|
||||
router.push(`${servicesBasePath}/internet/request`);
|
||||
};
|
||||
|
||||
// Auto eligibility request redirect
|
||||
useEffect(() => {
|
||||
if (!autoEligibilityRequest) return;
|
||||
if (!hasCheckedAuth) return;
|
||||
if (!user) return;
|
||||
if (!autoEligibilityRequest || !hasCheckedAuth || !user) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (autoPlanSku) params.set("planSku", autoPlanSku);
|
||||
@ -385,40 +327,6 @@ export function InternetPlansContainer() {
|
||||
router.replace(`${servicesBasePath}/internet/request${query.length > 0 ? `?${query}` : ""}`);
|
||||
}, [autoEligibilityRequest, autoPlanSku, hasCheckedAuth, servicesBasePath, user, router]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<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
|
||||
const currentStatus = isEligible
|
||||
? "eligible"
|
||||
@ -428,21 +336,22 @@ export function InternetPlansContainer() {
|
||||
? "ineligible"
|
||||
: "not_requested";
|
||||
|
||||
// Case 1: Unverified / Not Requested - Show Public Content exactly
|
||||
// Loading state
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<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) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-8">
|
||||
{/* Already has internet warning */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{hasActiveInternet && <ActiveInternetWarning />}
|
||||
<PublicInternetPlansContent
|
||||
onCtaClick={handleCheckAvailability}
|
||||
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 (
|
||||
<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" />
|
||||
|
||||
{/* Hero section - compact (for portal view) */}
|
||||
{/* Hero section */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground mb-2">
|
||||
Your Internet Options
|
||||
@ -470,7 +379,7 @@ export function InternetPlansContainer() {
|
||||
<EligibilityStatusBadge status={currentStatus} speed={eligibilityDisplay?.speed} />
|
||||
)}
|
||||
|
||||
{/* Loading states */}
|
||||
{/* Loading state */}
|
||||
{eligibilityLoading && (
|
||||
<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" />
|
||||
@ -479,143 +388,27 @@ export function InternetPlansContainer() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Already has internet warning */}
|
||||
{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>
|
||||
)}
|
||||
{hasActiveInternet && <ActiveInternetWarning />}
|
||||
|
||||
{/* ELIGIBLE STATE - Clean & Personalized */}
|
||||
{/* Eligible State */}
|
||||
{isEligible && eligibilityDisplay && offeringCards.length > 0 && (
|
||||
<>
|
||||
{/* 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"
|
||||
/>
|
||||
</>
|
||||
<InternetEligibleState
|
||||
offeringCards={offeringCards}
|
||||
hasActiveInternet={hasActiveInternet}
|
||||
servicesBasePath={servicesBasePath}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* PENDING STATE - Clean Status View */}
|
||||
{/* Pending State */}
|
||||
{isPending && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<InternetPendingState requestedAt={requestedAt} servicesBasePath={servicesBasePath} />
|
||||
)}
|
||||
|
||||
{/* INELIGIBLE STATE */}
|
||||
{isIneligible && (
|
||||
<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>
|
||||
)}
|
||||
{/* Ineligible State */}
|
||||
{isIneligible && <InternetIneligibleState rejectionNotes={rejectionNotes} />}
|
||||
|
||||
{/* No plans available */}
|
||||
{plans.length === 0 && !isLoading && (
|
||||
<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't find any internet plans at this time.
|
||||
</p>
|
||||
<ServicesBackLink href={servicesBasePath} label="Back to Services" align="center" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plans.length === 0 && !isLoading && <NoPlansAvailable servicesBasePath={servicesBasePath} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Wifi,
|
||||
Zap,
|
||||
Languages,
|
||||
FileText,
|
||||
Wrench,
|
||||
Globe,
|
||||
MapPin,
|
||||
Settings,
|
||||
Calendar,
|
||||
Router,
|
||||
} from "lucide-react";
|
||||
import { Wifi, MapPin, Settings, Calendar, Router } from "lucide-react";
|
||||
import { usePublicInternetCatalog } from "@/features/services/hooks";
|
||||
import type {
|
||||
InternetPlanCatalogItem,
|
||||
@ -24,13 +13,18 @@ import { ServicesBackLink } from "@/features/services/components/base/ServicesBa
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import { PublicOfferingCard } from "@/features/services/components/internet/PublicOfferingCard";
|
||||
import type { TierInfo } from "@/features/services/components/internet/PublicOfferingCard";
|
||||
import {
|
||||
ServiceHighlights,
|
||||
HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights";
|
||||
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
|
||||
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
|
||||
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
|
||||
import {
|
||||
OFFERING_CONFIGS,
|
||||
TIER_ORDER_MAP,
|
||||
getSpeedBadge,
|
||||
getTierDescription,
|
||||
getTierFeatures,
|
||||
INTERNET_FEATURES,
|
||||
} from "@/features/services/utils";
|
||||
|
||||
// Types
|
||||
interface GroupedOffering {
|
||||
@ -129,45 +123,6 @@ export function PublicInternetPlansContent({
|
||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||
|
||||
const internetFeatures: HighlightFeature[] = [
|
||||
{
|
||||
icon: <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
|
||||
const groupedOfferings = useMemo(() => {
|
||||
if (!servicesCatalog?.plans) return [];
|
||||
@ -202,38 +157,11 @@ export function PublicInternetPlansContent({
|
||||
}
|
||||
}
|
||||
|
||||
// Define offering metadata
|
||||
// Order: Home 10G first (premium), then Home 1G, then consolidated Apartment
|
||||
const offeringMeta: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
description: string;
|
||||
iconType: "home" | "apartment";
|
||||
order: number;
|
||||
isPremium?: boolean;
|
||||
}
|
||||
> = {
|
||||
"Home 10G": {
|
||||
title: "Home 10Gbps",
|
||||
description: "Ultra-fast fiber with the highest speeds available in Japan.",
|
||||
iconType: "home",
|
||||
order: 1,
|
||||
isPremium: true,
|
||||
},
|
||||
"Home 1G": {
|
||||
title: "Home 1Gbps",
|
||||
description: "High-speed fiber. The most popular choice for home internet.",
|
||||
iconType: "home",
|
||||
order: 2,
|
||||
},
|
||||
Apartment: {
|
||||
title: "Apartment",
|
||||
description:
|
||||
"For mansions and apartment buildings. Speed depends on your building (up to 1Gbps).",
|
||||
iconType: "apartment",
|
||||
order: 3,
|
||||
},
|
||||
// Use shared offering configs with display order override for public view
|
||||
const offeringDisplayOrder: Record<string, number> = {
|
||||
"Home 10G": 1,
|
||||
"Home 1G": 2,
|
||||
Apartment: 3,
|
||||
};
|
||||
|
||||
// Process Home offerings
|
||||
@ -241,28 +169,29 @@ export function PublicInternetPlansContent({
|
||||
// Skip apartment types - we'll handle them separately
|
||||
if (apartmentTypes.includes(offeringType)) continue;
|
||||
|
||||
const meta = offeringMeta[offeringType];
|
||||
const meta = OFFERING_CONFIGS[offeringType];
|
||||
if (!meta) continue;
|
||||
|
||||
// Sort plans by tier: Silver, Gold, Platinum
|
||||
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
|
||||
// Sort plans by tier using shared tier order
|
||||
const sortedPlans = [...plans].sort(
|
||||
(a, b) =>
|
||||
(tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99)
|
||||
(TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) -
|
||||
(TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99)
|
||||
);
|
||||
|
||||
// Calculate starting price
|
||||
const startingPrice = Math.min(...sortedPlans.map(p => p.monthlyPrice ?? 0));
|
||||
|
||||
// Get speed from offering type
|
||||
// Get speed from offering type using shared utility
|
||||
const speedBadge = getSpeedBadge(offeringType);
|
||||
|
||||
// Build tier info (no recommended badge in public view)
|
||||
// Build tier info using shared tier descriptions/features (public version)
|
||||
const tiers: TierInfo[] = sortedPlans.map(plan => ({
|
||||
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
|
||||
monthlyPrice: plan.monthlyPrice ?? 0,
|
||||
description: getTierDescription(plan.internetPlanTier ?? ""),
|
||||
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""),
|
||||
description: getTierDescription(plan.internetPlanTier ?? "", true),
|
||||
features:
|
||||
plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true),
|
||||
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined,
|
||||
}));
|
||||
|
||||
@ -281,10 +210,9 @@ export function PublicInternetPlansContent({
|
||||
|
||||
// Add consolidated Apartment offering (use any apartment plan for tiers - prices are the same)
|
||||
if (apartmentPlans.length > 0) {
|
||||
const meta = offeringMeta["Apartment"];
|
||||
const meta = OFFERING_CONFIGS["Apartment"];
|
||||
|
||||
// Get unique tiers from apartment plans (they all have same prices)
|
||||
const tierOrder: Record<string, number> = { Silver: 0, Gold: 1, Platinum: 2 };
|
||||
const uniqueTiers = new Map<string, InternetPlanCatalogItem>();
|
||||
|
||||
for (const plan of apartmentPlans) {
|
||||
@ -297,7 +225,8 @@ export function PublicInternetPlansContent({
|
||||
|
||||
const sortedTierPlans = Array.from(uniqueTiers.values()).sort(
|
||||
(a, b) =>
|
||||
(tierOrder[a.internetPlanTier ?? ""] ?? 99) - (tierOrder[b.internetPlanTier ?? ""] ?? 99)
|
||||
(TIER_ORDER_MAP[a.internetPlanTier ?? ""] ?? 99) -
|
||||
(TIER_ORDER_MAP[b.internetPlanTier ?? ""] ?? 99)
|
||||
);
|
||||
|
||||
const startingPrice = Math.min(...sortedTierPlans.map(p => p.monthlyPrice ?? 0));
|
||||
@ -305,17 +234,20 @@ export function PublicInternetPlansContent({
|
||||
const tiers: TierInfo[] = sortedTierPlans.map(plan => ({
|
||||
tier: (plan.internetPlanTier ?? "Silver") as TierInfo["tier"],
|
||||
monthlyPrice: plan.monthlyPrice ?? 0,
|
||||
description: getTierDescription(plan.internetPlanTier ?? ""),
|
||||
features: plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? ""),
|
||||
description: getTierDescription(plan.internetPlanTier ?? "", true),
|
||||
features:
|
||||
plan.catalogMetadata?.features ?? getTierFeatures(plan.internetPlanTier ?? "", true),
|
||||
pricingNote: plan.internetPlanTier === "Platinum" ? "+ equipment fees" : undefined,
|
||||
}));
|
||||
|
||||
offerings.push({
|
||||
offeringType: "Apartment",
|
||||
title: meta.title,
|
||||
speedBadge: "Up to 1Gbps",
|
||||
description: meta.description,
|
||||
iconType: meta.iconType,
|
||||
title: meta?.title ?? "Apartment",
|
||||
speedBadge: meta?.speedBadge ?? "Up to 1Gbps",
|
||||
description:
|
||||
meta?.description ??
|
||||
"For mansions and apartment buildings. Speed depends on your building.",
|
||||
iconType: meta?.iconType ?? "apartment",
|
||||
startingPrice,
|
||||
setupFee,
|
||||
tiers,
|
||||
@ -323,10 +255,10 @@ export function PublicInternetPlansContent({
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by order
|
||||
// Sort by display order
|
||||
return offerings.sort((a, b) => {
|
||||
const orderA = offeringMeta[a.offeringType]?.order ?? 99;
|
||||
const orderB = offeringMeta[b.offeringType]?.order ?? 99;
|
||||
const orderA = offeringDisplayOrder[a.offeringType] ?? 99;
|
||||
const orderB = offeringDisplayOrder[b.offeringType] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}, [servicesCatalog]);
|
||||
@ -378,7 +310,7 @@ export function PublicInternetPlansContent({
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
>
|
||||
<ServiceHighlights features={internetFeatures} />
|
||||
<ServiceHighlights features={INTERNET_FEATURES} />
|
||||
</section>
|
||||
|
||||
{/* Connection types section */}
|
||||
@ -450,34 +382,10 @@ export function PublicInternetPlansContent({
|
||||
* Clean, polished design optimized for conversion
|
||||
*/
|
||||
export function PublicInternetPlansView() {
|
||||
return <PublicInternetPlansContent />;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
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] ?? [];
|
||||
return (
|
||||
<PublicInternetPlansContent
|
||||
ctaPath="/services/internet/check-availability"
|
||||
ctaLabel="Check Availability"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,72 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Wifi,
|
||||
Router,
|
||||
Globe,
|
||||
Headphones,
|
||||
Package,
|
||||
CreditCard,
|
||||
Play,
|
||||
MonitorPlay,
|
||||
} from "lucide-react";
|
||||
import { ShieldCheck, Zap, CreditCard, Play, Globe, Package } from "lucide-react";
|
||||
import { usePublicVpnCatalog } from "@/features/services/hooks";
|
||||
import { VPN_FEATURES } from "@/features/services/utils";
|
||||
import { LoadingCard } from "@/components/atoms";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
||||
import { VpnPlanCard } from "@/features/services/components/vpn/VpnPlanCard";
|
||||
import { ServicesBackLink } from "@/features/services/components/base/ServicesBackLink";
|
||||
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
|
||||
import {
|
||||
ServiceHighlights,
|
||||
type HighlightFeature,
|
||||
} from "@/features/services/components/base/ServiceHighlights";
|
||||
import { ServiceHighlights } from "@/features/services/components/base/ServiceHighlights";
|
||||
import { HowItWorks, type HowItWorksStep } from "@/features/services/components/base/HowItWorks";
|
||||
import { ServiceCTA } from "@/features/services/components/base/ServiceCTA";
|
||||
import { ServiceFAQ, type FAQItem } from "@/features/services/components/base/ServiceFAQ";
|
||||
|
||||
// VPN-specific features for ServiceHighlights
|
||||
const vpnFeatures: HighlightFeature[] = [
|
||||
{
|
||||
icon: <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
|
||||
const vpnSteps: HowItWorksStep[] = [
|
||||
{
|
||||
@ -207,7 +154,7 @@ export function PublicVpnPlansView() {
|
||||
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
>
|
||||
<ServiceHighlights features={vpnFeatures} />
|
||||
<ServiceHighlights features={VPN_FEATURES} />
|
||||
</section>
|
||||
|
||||
{/* Plans Section */}
|
||||
|
||||
181
docs/features/unified-get-started-flow.md
Normal file
181
docs/features/unified-get-started-flow.md
Normal 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
|
||||
```
|
||||
231
docs/integrations/japanpost/api-reference.md
Normal file
231
docs/integrations/japanpost/api-reference.md
Normal 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
|
||||
@ -47,6 +47,34 @@ export const GENDER = {
|
||||
|
||||
export type GenderValue = (typeof GENDER)[keyof typeof GENDER];
|
||||
|
||||
// ============================================================================
|
||||
// Security Configuration Constants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Password reset token configuration
|
||||
* Single-use tokens tracked in Redis
|
||||
*/
|
||||
export const PASSWORD_RESET_CONFIG = {
|
||||
/** Time-to-live in seconds (15 minutes) */
|
||||
TTL_SECONDS: 900,
|
||||
/** Tokens can only be used once */
|
||||
SINGLE_USE: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* OTP (One-Time Password) configuration
|
||||
* Used for email verification in get-started flow
|
||||
*/
|
||||
export const OTP_CONFIG = {
|
||||
/** Time-to-live in seconds (10 minutes) */
|
||||
TTL_SECONDS: 600,
|
||||
/** Maximum verification attempts before invalidation */
|
||||
MAX_ATTEMPTS: 3,
|
||||
/** Length of generated code */
|
||||
CODE_LENGTH: 6,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Re-export Types from Schema (Schema-First Approach)
|
||||
// ============================================================================
|
||||
@ -69,6 +97,7 @@ export type {
|
||||
// Token types
|
||||
AuthTokens,
|
||||
AuthSession,
|
||||
PasswordResetTokenPayload,
|
||||
// Response types
|
||||
AuthResponse,
|
||||
SignupResult,
|
||||
|
||||
@ -17,6 +17,8 @@ export {
|
||||
AUTH_ERROR_CODE,
|
||||
TOKEN_TYPE,
|
||||
GENDER,
|
||||
PASSWORD_RESET_CONFIG,
|
||||
OTP_CONFIG,
|
||||
type AuthErrorCode,
|
||||
type TokenTypeValue,
|
||||
type GenderValue,
|
||||
@ -40,6 +42,7 @@ export type {
|
||||
// Token types
|
||||
AuthTokens,
|
||||
AuthSession,
|
||||
PasswordResetTokenPayload,
|
||||
// Response types
|
||||
AuthResponse,
|
||||
SignupResult,
|
||||
@ -77,6 +80,7 @@ export {
|
||||
// Token schemas
|
||||
authTokensSchema,
|
||||
authSessionSchema,
|
||||
passwordResetTokenPayloadSchema,
|
||||
|
||||
// Response schemas
|
||||
authResponseSchema,
|
||||
|
||||
@ -137,6 +137,21 @@ export const refreshTokenRequestSchema = z.object({
|
||||
// Token Schemas
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Password reset token payload schema
|
||||
* Used for validating JWT payload structure in password reset tokens
|
||||
*/
|
||||
export const passwordResetTokenPayloadSchema = z.object({
|
||||
/** User ID (standard JWT subject) */
|
||||
sub: z.string().uuid(),
|
||||
/** Unique token identifier for single-use tracking */
|
||||
tokenId: z.string().uuid(),
|
||||
/** Purpose claim to distinguish from other token types */
|
||||
purpose: z.literal("password_reset"),
|
||||
});
|
||||
|
||||
export type PasswordResetTokenPayload = z.infer<typeof passwordResetTokenPayloadSchema>;
|
||||
|
||||
export const authTokensSchema = z.object({
|
||||
accessToken: z.string().min(1, "Access token is required"),
|
||||
refreshToken: z.string().min(1, "Refresh token is required"),
|
||||
|
||||
112
packages/domain/get-started/contract.ts
Normal file
112
packages/domain/get-started/contract.ts
Normal 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;
|
||||
}
|
||||
58
packages/domain/get-started/index.ts
Normal file
58
packages/domain/get-started/index.ts
Normal 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";
|
||||
231
packages/domain/get-started/schema.ts
Normal file
231
packages/domain/get-started/schema.ts
Normal 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(),
|
||||
});
|
||||
@ -118,6 +118,10 @@
|
||||
"./address/providers": {
|
||||
"import": "./dist/address/providers/index.js",
|
||||
"types": "./dist/address/providers/index.d.ts"
|
||||
},
|
||||
"./get-started": {
|
||||
"import": "./dist/get-started/index.js",
|
||||
"types": "./dist/get-started/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@ -53,7 +53,6 @@ function baseProduct(
|
||||
id: product.Id,
|
||||
sku,
|
||||
name: product.Name ?? sku,
|
||||
catalogMetadata: {},
|
||||
};
|
||||
|
||||
if (product.Description) base.description = product.Description;
|
||||
@ -124,7 +123,6 @@ export function mapInternetInstallation(
|
||||
return {
|
||||
...base,
|
||||
catalogMetadata: {
|
||||
...base.catalogMetadata,
|
||||
installationTerm: inferInstallationTermFromSku(base.sku),
|
||||
},
|
||||
};
|
||||
@ -143,7 +141,6 @@ export function mapInternetAddon(
|
||||
bundledAddonId,
|
||||
isBundledAddon,
|
||||
catalogMetadata: {
|
||||
...base.catalogMetadata,
|
||||
addonType: inferAddonTypeFromSku(base.sku),
|
||||
},
|
||||
};
|
||||
@ -185,7 +182,6 @@ export function mapSimActivationFee(
|
||||
return {
|
||||
...simProduct,
|
||||
catalogMetadata: {
|
||||
...(simProduct.catalogMetadata ?? {}),
|
||||
isDefault: false, // Will be handled by service fallback
|
||||
},
|
||||
};
|
||||
|
||||
@ -11,6 +11,10 @@ import { addressSchema } from "../customer/index.js";
|
||||
// Base Catalog Product Schema
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Base catalog product schema without catalogMetadata.
|
||||
* Each specific product schema should define its own typed catalogMetadata if needed.
|
||||
*/
|
||||
export const catalogProductBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
sku: z.string(),
|
||||
@ -21,7 +25,6 @@ export const catalogProductBaseSchema = z.object({
|
||||
monthlyPrice: z.number().optional(),
|
||||
oneTimePrice: z.number().optional(),
|
||||
unitPrice: z.number().optional(),
|
||||
catalogMetadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
@ -74,6 +77,11 @@ export const internetInstallationCatalogItemSchema = internetCatalogProductSchem
|
||||
export const internetAddonCatalogItemSchema = internetCatalogProductSchema.extend({
|
||||
isBundledAddon: z.boolean().optional(),
|
||||
bundledAddonId: z.string().optional(),
|
||||
catalogMetadata: z
|
||||
.object({
|
||||
addonType: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const internetCatalogCollectionSchema = z.object({
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"address/**/*",
|
||||
"auth/**/*",
|
||||
"billing/**/*",
|
||||
"get-started/**/*",
|
||||
"services/**/*",
|
||||
"checkout/**/*",
|
||||
"common/**/*",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user