Merge branch 'main' into SIM_Tema

This commit is contained in:
NTumurbars 2025-12-01 10:22:14 +09:00 committed by GitHub
commit 1d4891ad85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 4328 additions and 2376 deletions

View File

@ -106,7 +106,7 @@ COPY --from=builder /app/apps/bff/prisma ./apps/bff/prisma
# Generate Prisma client in production environment # Generate Prisma client in production environment
WORKDIR /app/apps/bff WORKDIR /app/apps/bff
RUN pnpm dlx prisma generate RUN pnpm dlx prisma@6.14.0 generate
# Strip build toolchain to shrink image # Strip build toolchain to shrink image
RUN apk del --no-cache python3 make g++ pkgconfig && rm -rf /root/.cache /var/cache/apk/* RUN apk del --no-cache python3 make g++ pkgconfig && rm -rf /root/.cache /var/cache/apk/*

View File

@ -17,9 +17,7 @@ declare global {
} }
/* eslint-enable @typescript-eslint/no-namespace */ /* eslint-enable @typescript-eslint/no-namespace */
import { GlobalExceptionFilter } from "../core/http/http-exception.filter"; import { UnifiedExceptionFilter } from "../core/http/exception.filter";
import { AuthErrorFilter } from "../core/http/auth-error.filter";
import { SecureErrorMapperService } from "../core/security/services/secure-error-mapper.service";
import { AppModule } from "../app.module"; import { AppModule } from "../app.module";
@ -116,11 +114,8 @@ export async function bootstrap(): Promise<INestApplication> {
maxAge: 86400, // 24 hours maxAge: 86400, // 24 hours
}); });
// Global exception filters // Global exception filter - single unified filter for all errors
app.useGlobalFilters( app.useGlobalFilters(new UnifiedExceptionFilter(app.get(Logger), app.get(ConfigService)));
new AuthErrorFilter(app.get(Logger)), // Handle auth errors first
new GlobalExceptionFilter(app.get(Logger), app.get(SecureErrorMapperService)) // Handle all other errors
);
// Global authentication guard will be registered via APP_GUARD provider in AuthModule // Global authentication guard will be registered via APP_GUARD provider in AuthModule

View File

@ -1,175 +0,0 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
UnauthorizedException,
ForbiddenException,
BadRequestException,
ConflictException,
HttpStatus,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
/**
* Standard error response matching domain apiErrorResponseSchema
*/
interface StandardErrorResponse {
success: false;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
@Catch(UnauthorizedException, ForbiddenException, BadRequestException, ConflictException)
export class AuthErrorFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
catch(
exception: UnauthorizedException | ForbiddenException | BadRequestException | ConflictException,
host: ArgumentsHost
) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus() as HttpStatus;
const responsePayload = exception.getResponse();
const payloadMessage =
typeof responsePayload === "string"
? responsePayload
: Array.isArray((responsePayload as { message?: unknown })?.message)
? (responsePayload as { message: unknown[] }).message.find(
(value): value is string => typeof value === "string"
)
: (responsePayload as { message?: unknown })?.message;
const exceptionMessage = typeof exception.message === "string" ? exception.message : undefined;
const messageText =
payloadMessage && typeof payloadMessage === "string"
? payloadMessage
: (exceptionMessage ?? "Authentication error");
// Map specific auth errors to user-friendly messages
const userMessage = this.getUserFriendlyMessage(messageText, status);
const errorCode = this.getErrorCode(messageText, status);
// Log the error (without sensitive information)
const userAgentHeader = request.headers["user-agent"];
const userAgent =
typeof userAgentHeader === "string"
? userAgentHeader
: Array.isArray(userAgentHeader)
? userAgentHeader[0]
: undefined;
this.logger.warn("Authentication error", {
path: request.url,
method: request.method,
errorCode,
userAgent,
ip: request.ip,
});
const errorResponse: StandardErrorResponse = {
success: false as const,
error: {
code: errorCode,
message: userMessage,
details: {
timestamp: new Date().toISOString(),
path: request.url,
statusCode: status,
},
},
};
response.status(status).json(errorResponse);
}
private getUserFriendlyMessage(message: string, status: HttpStatus): string {
// Production-safe error messages that don't expose sensitive information
if (status === HttpStatus.UNAUTHORIZED) {
if (
message.includes("Invalid credentials") ||
message.includes("Invalid email or password")
) {
return "Invalid email or password. Please try again.";
}
if (message.includes("Token has been revoked") || message.includes("Invalid refresh token")) {
return "Your session has expired. Please log in again.";
}
if (message.includes("Account is locked")) {
return "Your account has been temporarily locked due to multiple failed login attempts. Please try again later.";
}
if (message.includes("Unable to verify credentials")) {
return "Unable to verify credentials. Please try again later.";
}
if (message.includes("Unable to verify account")) {
return "Unable to verify account. Please try again later.";
}
return "Authentication required. Please log in to continue.";
}
if (status === HttpStatus.FORBIDDEN) {
if (message.includes("Admin access required")) {
return "You do not have permission to access this resource.";
}
return "Access denied. You do not have permission to perform this action.";
}
if (status === HttpStatus.BAD_REQUEST) {
if (message.includes("Salesforce account not found")) {
return "Customer account not found. Please contact support.";
}
if (message.includes("Unable to verify customer information")) {
return "Unable to verify customer information. Please contact support.";
}
return "Invalid request. Please check your input and try again.";
}
if (status === HttpStatus.CONFLICT) {
if (message.includes("already linked")) {
return "This billing account is already linked. Please sign in.";
}
if (message.includes("already exists")) {
return "An account with this email already exists. Please sign in.";
}
return "Conflict detected. Please try again.";
}
return "Authentication error. Please try again.";
}
private getErrorCode(message: string, status: HttpStatus): string {
if (status === HttpStatus.UNAUTHORIZED) {
if (message.includes("Invalid credentials") || message.includes("Invalid email or password"))
return "INVALID_CREDENTIALS";
if (message.includes("Token has been revoked")) return "TOKEN_REVOKED";
if (message.includes("Invalid refresh token")) return "INVALID_REFRESH_TOKEN";
if (message.includes("Account is locked")) return "ACCOUNT_LOCKED";
if (message.includes("Unable to verify credentials")) return "SERVICE_UNAVAILABLE";
if (message.includes("Unable to verify account")) return "SERVICE_UNAVAILABLE";
return "UNAUTHORIZED";
}
if (status === HttpStatus.FORBIDDEN) {
if (message.includes("Admin access required")) return "ADMIN_REQUIRED";
return "FORBIDDEN";
}
if (status === HttpStatus.BAD_REQUEST) {
if (message.includes("Salesforce account not found")) return "CUSTOMER_NOT_FOUND";
if (message.includes("Unable to verify customer information")) return "SERVICE_UNAVAILABLE";
return "INVALID_REQUEST";
}
if (status === HttpStatus.CONFLICT) {
if (message.includes("already linked")) return "ACCOUNT_ALREADY_LINKED";
if (message.includes("already exists")) return "ACCOUNT_EXISTS";
return "CONFLICT";
}
return "AUTH_ERROR";
}
}

View File

@ -0,0 +1,304 @@
/**
* Unified Exception Filter
*
* Single exception filter that handles all HTTP exceptions consistently.
* Uses the shared error codes and messages from the domain package.
*/
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Inject,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import {
ErrorCode,
ErrorMessages,
ErrorMetadata,
matchErrorPattern,
type ErrorCodeType,
type ApiError,
} from "@customer-portal/domain/common";
/**
* Request context for error logging
*/
interface ErrorContext {
requestId: string;
userId?: string;
method: string;
path: string;
userAgent?: string;
ip?: string;
}
/**
* Unified exception filter for all HTTP errors.
* Provides consistent error responses and secure logging.
*/
@Catch()
export class UnifiedExceptionFilter implements ExceptionFilter {
private readonly isDevelopment: boolean;
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly configService: ConfigService
) {
this.isDevelopment = this.configService.get("NODE_ENV") !== "production";
}
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { user?: { id?: string }; requestId?: string }>();
// Build error context for logging
const errorContext = this.buildErrorContext(request);
// Extract status code and error details
const { status, errorCode, originalMessage } = this.extractErrorDetails(exception);
// Get user-friendly message (with dev details if in development)
const userMessage = this.getUserMessage(errorCode, originalMessage);
// Log the error
this.logError(errorCode, originalMessage, status, errorContext, exception);
// Build and send response
const errorResponse = this.buildErrorResponse(errorCode, userMessage, status, errorContext);
response.status(status).json(errorResponse);
}
/**
* Extract error details from exception
*/
private extractErrorDetails(exception: unknown): {
status: number;
errorCode: ErrorCodeType;
originalMessage: string;
} {
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let originalMessage = "An unexpected error occurred";
if (exception instanceof HttpException) {
status = exception.getStatus();
originalMessage = this.extractExceptionMessage(exception);
} else if (exception instanceof Error) {
originalMessage = exception.message;
}
// Map to error code
const errorCode = this.mapToErrorCode(originalMessage, status);
return { status, errorCode, originalMessage };
}
/**
* Extract message from HttpException
*/
private extractExceptionMessage(exception: HttpException): string {
const response = exception.getResponse();
if (typeof response === "string") {
return response;
}
if (typeof response === "object" && response !== null) {
const responseObj = response as Record<string, unknown>;
// Handle NestJS validation errors (array of messages)
if (Array.isArray(responseObj.message)) {
const firstMessage = responseObj.message.find(
(m): m is string => typeof m === "string"
);
if (firstMessage) return firstMessage;
}
// Handle standard message field
if (typeof responseObj.message === "string") {
return responseObj.message;
}
// Handle error field
if (typeof responseObj.error === "string") {
return responseObj.error;
}
}
return exception.message;
}
/**
* Map error message and status to error code
*/
private mapToErrorCode(message: string, status: number): ErrorCodeType {
// First, try pattern matching on the message
const patternCode = matchErrorPattern(message);
if (patternCode !== ErrorCode.UNKNOWN) {
return patternCode;
}
// Fall back to status code mapping
switch (status) {
case HttpStatus.UNAUTHORIZED:
return ErrorCode.SESSION_EXPIRED;
case HttpStatus.FORBIDDEN:
return ErrorCode.FORBIDDEN;
case HttpStatus.NOT_FOUND:
return ErrorCode.NOT_FOUND;
case HttpStatus.CONFLICT:
return ErrorCode.ACCOUNT_EXISTS;
case HttpStatus.BAD_REQUEST:
return ErrorCode.VALIDATION_FAILED;
case HttpStatus.TOO_MANY_REQUESTS:
return ErrorCode.RATE_LIMITED;
case HttpStatus.SERVICE_UNAVAILABLE:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
/**
* Get user-friendly message, with dev details in development mode
*/
private getUserMessage(errorCode: ErrorCodeType, originalMessage: string): string {
const userMessage = ErrorMessages[errorCode] ?? ErrorMessages[ErrorCode.UNKNOWN];
if (this.isDevelopment && originalMessage !== userMessage) {
// In dev mode, append original message for debugging
const sanitized = this.sanitizeForDev(originalMessage);
if (sanitized && sanitized !== userMessage) {
return `${userMessage} (Dev: ${sanitized})`;
}
}
return userMessage;
}
/**
* Sanitize message for development display
*/
private sanitizeForDev(message: string): string {
return message
.replace(/password[=:]\s*[^\s]+/gi, "password=[HIDDEN]")
.replace(/secret[=:]\s*[^\s]+/gi, "secret=[HIDDEN]")
.replace(/token[=:]\s*[^\s]+/gi, "token=[HIDDEN]")
.replace(/key[=:]\s*[^\s]+/gi, "key=[HIDDEN]")
.substring(0, 200); // Limit length
}
/**
* Build error context from request
*/
private buildErrorContext(
request: Request & { user?: { id?: string }; requestId?: string }
): ErrorContext {
const userAgentHeader = request.headers["user-agent"];
return {
requestId: request.requestId ?? this.generateRequestId(),
userId: request.user?.id,
method: request.method,
path: request.url,
userAgent:
typeof userAgentHeader === "string"
? userAgentHeader
: Array.isArray(userAgentHeader)
? userAgentHeader[0]
: undefined,
ip: request.ip,
};
}
/**
* Log error with appropriate level based on metadata
*/
private logError(
errorCode: ErrorCodeType,
originalMessage: string,
status: number,
context: ErrorContext,
exception: unknown
): void {
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
const logData = {
errorCode,
category: metadata.category,
severity: metadata.severity,
statusCode: status,
originalMessage: this.sanitizeForLogging(originalMessage),
...context,
stack: exception instanceof Error ? exception.stack : undefined,
};
// Log based on severity
switch (metadata.logLevel) {
case "error":
this.logger.error(`HTTP ${status} Error [${errorCode}]`, logData);
break;
case "warn":
this.logger.warn(`HTTP ${status} Warning [${errorCode}]`, logData);
break;
case "info":
this.logger.log(`HTTP ${status} Info [${errorCode}]`, logData);
break;
case "debug":
this.logger.debug(`HTTP ${status} Debug [${errorCode}]`, logData);
break;
}
}
/**
* Sanitize message for logging (remove sensitive info)
*/
private sanitizeForLogging(message: string): string {
return message
.replace(/password[=:]\s*[^\s]+/gi, "password=[REDACTED]")
.replace(/secret[=:]\s*[^\s]+/gi, "secret=[REDACTED]")
.replace(/token[=:]\s*[^\s]+/gi, "token=[REDACTED]")
.replace(/\b[A-Za-z0-9]{32,}\b/g, "[TOKEN]")
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[IP]");
}
/**
* Build standard error response
*/
private buildErrorResponse(
errorCode: ErrorCodeType,
message: string,
status: number,
context: ErrorContext
): ApiError {
const metadata = ErrorMetadata[errorCode] ?? ErrorMetadata[ErrorCode.UNKNOWN];
return {
success: false,
error: {
code: errorCode,
message,
details: {
statusCode: status,
category: metadata.category,
timestamp: new Date().toISOString(),
path: context.path,
requestId: context.requestId,
},
},
};
}
/**
* Generate unique request ID
*/
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
}

View File

@ -1,102 +0,0 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Inject,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
import { SecureErrorMapperService } from "../security/services/secure-error-mapper.service";
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly secureErrorMapper: SecureErrorMapperService
) {}
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request & { user?: { id?: string }; requestId?: string }>();
// Create error context for secure mapping
const errorContext = {
userId: request.user?.id,
requestId: request.requestId || this.generateRequestId(),
userAgent: request.get("user-agent"),
ip: request.ip,
url: request.url,
method: request.method,
};
let status: number;
let originalError: unknown = exception;
// Determine HTTP status
if (exception instanceof HttpException) {
status = exception.getStatus();
// Extract the actual error from HttpException response
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
const errorResponse = exceptionResponse as { message?: string; error?: string };
originalError = errorResponse.message || exception.message;
} else {
originalError =
typeof exceptionResponse === "string" ? exceptionResponse : exception.message;
}
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
originalError = exception;
}
// Use secure error mapper to get safe public message and log securely
const errorClassification = this.secureErrorMapper.mapError(originalError, errorContext);
const publicMessage = this.secureErrorMapper.getPublicMessage(originalError, errorContext);
// Log the error securely (this handles sensitive data filtering)
this.secureErrorMapper.logSecureError(originalError, errorContext, {
httpStatus: status,
exceptionType: exception instanceof Error ? exception.constructor.name : "Unknown",
});
// Create secure error response matching domain apiErrorResponseSchema
const errorResponse = {
success: false as const,
error: {
code: errorClassification.mapping.code,
message: publicMessage,
details: {
statusCode: status,
category: errorClassification.category.toUpperCase(),
timestamp: new Date().toISOString(),
path: request.url,
requestId: errorContext.requestId,
},
},
};
// Additional logging for monitoring (without sensitive data)
this.logger.error(`HTTP ${status} Error [${errorClassification.mapping.code}]`, {
statusCode: status,
method: request.method,
url: request.url,
userAgent: request.get("user-agent"),
ip: request.ip,
errorCode: errorClassification.mapping.code,
category: errorClassification.category,
severity: errorClassification.severity,
requestId: errorContext.requestId,
userId: errorContext.userId,
});
response.status(status).json(errorResponse);
}
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
}

View File

@ -205,6 +205,14 @@ export class SecureErrorMapperService {
logLevel: "info", logLevel: "info",
}, },
], ],
[
"ENDPOINT_NOT_FOUND",
{
code: "VAL_003",
publicMessage: "The requested resource was not found",
logLevel: "info",
},
],
// Business Logic Errors // Business Logic Errors
[ [
@ -357,6 +365,16 @@ export class SecureErrorMapperService {
}, },
}, },
// HTTP/Routing patterns
{
pattern: /^Cannot\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+/i,
mapping: {
code: "VAL_003",
publicMessage: "The requested resource was not found",
logLevel: "info",
},
},
// Validation patterns // Validation patterns
{ {
pattern: /invalid|required|missing|validation|format/i, pattern: /invalid|required|missing|validation|format/i,

View File

@ -5,6 +5,7 @@ import { SalesforceService } from "./salesforce.service";
import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceConnection } from "./services/salesforce-connection.service";
import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceAccountService } from "./services/salesforce-account.service";
import { SalesforceOrderService } from "./services/salesforce-order.service"; import { SalesforceOrderService } from "./services/salesforce-order.service";
import { SalesforceCaseService } from "./services/salesforce-case.service";
import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module"; import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module";
import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard"; import { SalesforceReadThrottleGuard } from "./guards/salesforce-read-throttle.guard";
import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard"; import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle.guard";
@ -15,6 +16,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
SalesforceConnection, SalesforceConnection,
SalesforceAccountService, SalesforceAccountService,
SalesforceOrderService, SalesforceOrderService,
SalesforceCaseService,
SalesforceService, SalesforceService,
SalesforceReadThrottleGuard, SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard, SalesforceWriteThrottleGuard,
@ -24,6 +26,7 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
SalesforceService, SalesforceService,
SalesforceConnection, SalesforceConnection,
SalesforceOrderService, SalesforceOrderService,
SalesforceCaseService,
SalesforceReadThrottleGuard, SalesforceReadThrottleGuard,
SalesforceWriteThrottleGuard, SalesforceWriteThrottleGuard,
], ],

View File

@ -0,0 +1,209 @@
/**
* Salesforce Case Integration Service
*
* Encapsulates all Salesforce Case operations for the portal.
* - Queries cases filtered by Origin = 'Portal Website'
* - Creates cases with portal-specific defaults
* - Validates account ownership for security
*
* Uses domain types and mappers from @customer-portal/domain/support
*/
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "./salesforce-connection.service";
import { assertSalesforceId } from "../utils/soql.util";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceResponse } from "@customer-portal/domain/common";
import {
type SupportCase,
type SalesforceCaseRecord,
type CreateCaseRequest,
PORTAL_CASE_ORIGIN,
SALESFORCE_CASE_ORIGIN,
SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY,
Providers,
} from "@customer-portal/domain/support";
// Import the reverse mapping function
const { toSalesforcePriority } = Providers.Salesforce;
/**
* Parameters for creating a case in Salesforce
* Extends domain CreateCaseRequest with infrastructure-specific fields
*/
export interface CreateCaseParams extends CreateCaseRequest {
/** Salesforce Account ID */
accountId: string;
/** Optional Salesforce Contact ID */
contactId?: string;
}
@Injectable()
export class SalesforceCaseService {
constructor(
private readonly sf: SalesforceConnection,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Get all cases for an account filtered by Portal Website origin
*/
async getCasesForAccount(accountId: string): Promise<SupportCase[]> {
const safeAccountId = assertSalesforceId(accountId, "accountId");
this.logger.debug({ accountId: safeAccountId }, "Fetching portal cases for account");
const soql = Providers.Salesforce.buildCasesForAccountQuery(
safeAccountId,
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE
);
try {
const result = (await this.sf.query(soql, {
label: "support:listCasesForAccount",
})) as SalesforceResponse<SalesforceCaseRecord>;
const cases = result.records || [];
this.logger.debug(
{ accountId: safeAccountId, caseCount: cases.length },
"Portal cases retrieved for account"
);
return Providers.Salesforce.transformSalesforceCasesToSupportCases(cases);
} catch (error: unknown) {
this.logger.error("Failed to fetch cases for account", {
error: getErrorMessage(error),
accountId: safeAccountId,
});
throw new Error("Failed to fetch support cases");
}
}
/**
* Get a single case by ID with account ownership validation
*/
async getCaseById(caseId: string, accountId: string): Promise<SupportCase | null> {
const safeCaseId = assertSalesforceId(caseId, "caseId");
const safeAccountId = assertSalesforceId(accountId, "accountId");
this.logger.debug({ caseId: safeCaseId, accountId: safeAccountId }, "Fetching case by ID");
const soql = Providers.Salesforce.buildCaseByIdQuery(
safeCaseId,
safeAccountId,
SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE
);
try {
const result = (await this.sf.query(soql, {
label: "support:getCaseById",
})) as SalesforceResponse<SalesforceCaseRecord>;
const record = result.records?.[0];
if (!record) {
this.logger.debug({ caseId: safeCaseId }, "Case not found or access denied");
return null;
}
return Providers.Salesforce.transformSalesforceCaseToSupportCase(record);
} catch (error: unknown) {
this.logger.error("Failed to fetch case by ID", {
error: getErrorMessage(error),
caseId: safeCaseId,
});
throw new Error("Failed to fetch support case");
}
}
/**
* Create a new case with Portal Website origin
*/
async createCase(params: CreateCaseParams): Promise<{ id: string; caseNumber: string }> {
const safeAccountId = assertSalesforceId(params.accountId, "accountId");
const safeContactId = params.contactId
? assertSalesforceId(params.contactId, "contactId")
: undefined;
this.logger.log(
{ accountId: safeAccountId, subject: params.subject },
"Creating portal support case"
);
// Build case payload with portal defaults
// Convert portal display values to Salesforce API values
const sfPriority = params.priority
? toSalesforcePriority(params.priority)
: SALESFORCE_CASE_PRIORITY.MEDIUM;
const casePayload: Record<string, unknown> = {
Origin: SALESFORCE_CASE_ORIGIN.PORTAL_WEBSITE,
Status: SALESFORCE_CASE_STATUS.NEW,
Priority: sfPriority,
Subject: params.subject.trim(),
Description: params.description.trim(),
};
// Set ContactId if available - Salesforce will auto-populate AccountId from Contact
// If no ContactId, we must set AccountId directly (requires FLS write permission)
if (safeContactId) {
casePayload.ContactId = safeContactId;
} else {
// Only set AccountId when no ContactId is available
// Note: This requires AccountId field-level security write permission
casePayload.AccountId = safeAccountId;
}
// Note: Category maps to Salesforce Type field
// Only set if Type picklist is configured in Salesforce
// Currently skipped as Type picklist values are unknown
try {
const created = (await this.sf.sobject("Case").create(casePayload)) as { id?: string };
if (!created.id) {
throw new Error("Salesforce did not return a case ID");
}
// Fetch the created case to get the CaseNumber
const createdCase = await this.getCaseByIdInternal(created.id);
const caseNumber = createdCase?.CaseNumber ?? created.id;
this.logger.log(
{ caseId: created.id, caseNumber },
"Portal support case created successfully"
);
return { id: created.id, caseNumber };
} catch (error: unknown) {
this.logger.error("Failed to create support case", {
error: getErrorMessage(error),
accountId: safeAccountId,
});
throw new Error("Failed to create support case");
}
}
/**
* Internal method to fetch case without account validation (for post-create lookup)
*/
private async getCaseByIdInternal(caseId: string): Promise<SalesforceCaseRecord | null> {
const safeCaseId = assertSalesforceId(caseId, "caseId");
const fields = Providers.Salesforce.buildCaseSelectFields().join(", ");
const soql = `
SELECT ${fields}
FROM Case
WHERE Id = '${safeCaseId}'
LIMIT 1
`;
const result = (await this.sf.query(soql, {
label: "support:getCaseByIdInternal",
})) as SalesforceResponse<SalesforceCaseRecord>;
return result.records?.[0] ?? null;
}
}

View File

@ -17,6 +17,12 @@ export interface CatalogCacheOptions<T> {
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined; ) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
} }
interface LegacyCatalogCachePayload<T> {
value: T;
__catalogCache?: boolean;
dependencies?: CacheDependencies;
}
/** /**
* Catalog cache service * Catalog cache service
* *
@ -188,13 +194,12 @@ export class CatalogCacheService {
const allowNull = options?.allowNull ?? false; const allowNull = options?.allowNull ?? false;
// Check Redis cache first // Check Redis cache first
const cached = await this.cache.get<T>(key); const cached = await this.cache.get<T | LegacyCatalogCachePayload<T>>(key);
if (cached !== null) { if (cached !== null) {
if (allowNull || cached !== null) { const normalized = await this.normalizeCachedValue(key, cached, ttlSeconds);
this.metrics[bucket].hits++; this.metrics[bucket].hits++;
return cached; return normalized;
}
} }
// Check for in-flight request (prevents thundering herd) // Check for in-flight request (prevents thundering herd)
@ -243,6 +248,43 @@ export class CatalogCacheService {
return fetchPromise; return fetchPromise;
} }
private async normalizeCachedValue<T>(
key: string,
cached: T | LegacyCatalogCachePayload<T>,
ttlSeconds: number | null
): Promise<T> {
if (this.isLegacyCatalogCachePayload<T>(cached)) {
const ttlArg = ttlSeconds === null ? undefined : ttlSeconds;
const normalizedValue = cached.value;
if (ttlArg === undefined) {
await this.cache.set(key, normalizedValue);
} else {
await this.cache.set(key, normalizedValue, ttlArg);
}
if (cached.dependencies) {
await this.storeDependencies(key, cached.dependencies);
await this.linkDependencies(key, cached.dependencies);
}
return normalizedValue;
}
return cached;
}
private isLegacyCatalogCachePayload<T>(
payload: unknown
): payload is LegacyCatalogCachePayload<T> {
if (!payload || typeof payload !== "object") {
return false;
}
const record = payload as Record<string, unknown>;
return record.__catalogCache === true && Object.prototype.hasOwnProperty.call(record, "value");
}
/** /**
* Invalidate catalog entries by product IDs * Invalidate catalog entries by product IDs
* Returns true if any entries were invalidated, false if no matches found * Returns true if any entries were invalidated, false if no matches found

View File

@ -1,10 +1,14 @@
import { Controller, Get, Query, Request } from "@nestjs/common"; import { Controller, Get, Post, Query, Param, Body, Request } from "@nestjs/common";
import { SupportService } from "./support.service"; import { SupportService } from "./support.service";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import { import {
supportCaseFilterSchema, supportCaseFilterSchema,
createCaseRequestSchema,
type SupportCaseFilter, type SupportCaseFilter,
type SupportCaseList, type SupportCaseList,
type SupportCase,
type CreateCaseRequest,
type CreateCaseResponse,
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ -14,11 +18,27 @@ export class SupportController {
@Get("cases") @Get("cases")
async listCases( async listCases(
@Request() _req: RequestWithUser, @Request() req: RequestWithUser,
@Query(new ZodValidationPipe(supportCaseFilterSchema)) @Query(new ZodValidationPipe(supportCaseFilterSchema))
filters: SupportCaseFilter filters: SupportCaseFilter
): Promise<SupportCaseList> { ): Promise<SupportCaseList> {
void _req; return this.supportService.listCases(req.user.id, filters);
return this.supportService.listCases(filters); }
@Get("cases/:id")
async getCase(
@Request() req: RequestWithUser,
@Param("id") caseId: string
): Promise<SupportCase> {
return this.supportService.getCase(req.user.id, caseId);
}
@Post("cases")
async createCase(
@Request() req: RequestWithUser,
@Body(new ZodValidationPipe(createCaseRequestSchema))
body: CreateCaseRequest
): Promise<CreateCaseResponse> {
return this.supportService.createCase(req.user.id, body);
} }
} }

View File

@ -1,8 +1,11 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { SupportController } from "./support.controller"; import { SupportController } from "./support.controller";
import { SupportService } from "./support.service"; import { SupportService } from "./support.service";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
@Module({ @Module({
imports: [SalesforceModule, MappingsModule],
controllers: [SupportController], controllers: [SupportController],
providers: [SupportService], providers: [SupportService],
exports: [SupportService], exports: [SupportService],

View File

@ -1,110 +1,158 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Inject, NotFoundException, ForbiddenException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { import {
SUPPORT_CASE_CATEGORY,
SUPPORT_CASE_PRIORITY, SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_STATUS, SUPPORT_CASE_STATUS,
supportCaseFilterSchema,
supportCaseListSchema,
type SupportCase, type SupportCase,
type SupportCaseFilter, type SupportCaseFilter,
type SupportCaseList, type SupportCaseList,
type SupportCasePriority, type CreateCaseRequest,
type SupportCaseStatus, type CreateCaseResponse,
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
const OPEN_STATUSES: SupportCaseStatus[] = [ /**
SUPPORT_CASE_STATUS.OPEN, * Status values that indicate an open/active case
* (Display values after mapping from Salesforce Japanese API names)
*/
const OPEN_STATUSES: string[] = [
SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS, SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER, SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
]; ];
const RESOLVED_STATUSES: SupportCaseStatus[] = [ /**
* Status values that indicate a resolved/closed case
* (Display values after mapping from Salesforce Japanese API names)
*/
const RESOLVED_STATUSES: string[] = [
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED, SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED, SUPPORT_CASE_STATUS.CLOSED,
]; ];
const HIGH_PRIORITIES: SupportCasePriority[] = [ /**
SUPPORT_CASE_PRIORITY.HIGH, * Priority values that indicate high priority
SUPPORT_CASE_PRIORITY.CRITICAL, */
]; const HIGH_PRIORITIES: string[] = [SUPPORT_CASE_PRIORITY.HIGH];
@Injectable() @Injectable()
export class SupportService { export class SupportService {
// Placeholder dataset until Salesforce integration is ready constructor(
private readonly cases: SupportCase[] = [ private readonly caseService: SalesforceCaseService,
{ private readonly mappingsService: MappingsService,
id: 12001, @Inject(Logger) private readonly logger: Logger
subject: "VPS Performance Issues", ) {}
status: SUPPORT_CASE_STATUS.IN_PROGRESS,
priority: SUPPORT_CASE_PRIORITY.HIGH,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-14T10:30:00Z",
updatedAt: "2025-08-15T14:20:00Z",
lastReply: "2025-08-15T14:20:00Z",
description: "Experiencing slow response times on VPS server, CPU usage appears high.",
assignedTo: "Technical Support Team",
},
{
id: 12002,
subject: "Billing Question - Invoice #12345",
status: SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER,
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
category: SUPPORT_CASE_CATEGORY.BILLING,
createdAt: "2025-08-13T16:45:00Z",
updatedAt: "2025-08-14T09:30:00Z",
lastReply: "2025-08-14T09:30:00Z",
description: "Need clarification on charges in recent invoice.",
assignedTo: "Billing Department",
},
{
id: 12003,
subject: "SSL Certificate Installation",
status: SUPPORT_CASE_STATUS.RESOLVED,
priority: SUPPORT_CASE_PRIORITY.LOW,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-12T08:15:00Z",
updatedAt: "2025-08-12T15:45:00Z",
lastReply: "2025-08-12T15:45:00Z",
description: "Request assistance with SSL certificate installation on shared hosting.",
assignedTo: "Technical Support Team",
},
{
id: 12004,
subject: "Feature Request: Control Panel Enhancement",
status: SUPPORT_CASE_STATUS.OPEN,
priority: SUPPORT_CASE_PRIORITY.LOW,
category: SUPPORT_CASE_CATEGORY.FEATURE_REQUEST,
createdAt: "2025-08-11T13:20:00Z",
updatedAt: "2025-08-11T13:20:00Z",
description: "Would like to see improved backup management in the control panel.",
assignedTo: "Development Team",
},
{
id: 12005,
subject: "Server Migration Assistance",
status: SUPPORT_CASE_STATUS.CLOSED,
priority: SUPPORT_CASE_PRIORITY.MEDIUM,
category: SUPPORT_CASE_CATEGORY.TECHNICAL,
createdAt: "2025-08-10T11:00:00Z",
updatedAt: "2025-08-11T17:30:00Z",
lastReply: "2025-08-11T17:30:00Z",
description: "Need help migrating website from old server to new VPS.",
assignedTo: "Migration Team",
},
];
async listCases(rawFilters?: SupportCaseFilter): Promise<SupportCaseList> { /**
const filters = supportCaseFilterSchema.parse(rawFilters ?? {}); * List cases for a user with optional filters
const filteredCases = this.applyFilters(this.cases, filters); */
const result = { async listCases(userId: string, filters?: SupportCaseFilter): Promise<SupportCaseList> {
cases: filteredCases, const accountId = await this.getAccountIdForUser(userId);
summary: this.buildSummary(filteredCases),
}; try {
return supportCaseListSchema.parse(result); // SalesforceCaseService now returns SupportCase[] directly using domain mappers
const cases = await this.caseService.getCasesForAccount(accountId);
const filteredCases = this.applyFilters(cases, filters);
const summary = this.buildSummary(filteredCases);
return { cases: filteredCases, summary };
} catch (error) {
this.logger.error("Failed to list support cases", {
userId,
error: getErrorMessage(error),
});
throw error;
}
} }
private applyFilters(cases: SupportCase[], filters: SupportCaseFilter): SupportCase[] { /**
* Get a single case by ID
*/
async getCase(userId: string, caseId: string): Promise<SupportCase> {
const accountId = await this.getAccountIdForUser(userId);
try {
// SalesforceCaseService now returns SupportCase directly using domain mappers
const supportCase = await this.caseService.getCaseById(caseId, accountId);
if (!supportCase) {
throw new NotFoundException("Support case not found");
}
return supportCase;
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error("Failed to get support case", {
userId,
caseId,
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Create a new support case
*/
async createCase(userId: string, request: CreateCaseRequest): Promise<CreateCaseResponse> {
const accountId = await this.getAccountIdForUser(userId);
try {
const result = await this.caseService.createCase({
subject: request.subject,
description: request.description,
category: request.category,
priority: request.priority,
accountId,
});
this.logger.log("Support case created", {
userId,
caseId: result.id,
caseNumber: result.caseNumber,
});
return result;
} catch (error) {
this.logger.error("Failed to create support case", {
userId,
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Get Salesforce account ID for a user
*/
private async getAccountIdForUser(userId: string): Promise<string> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.sfAccountId) {
this.logger.warn("No Salesforce account mapping found for user", { userId });
throw new ForbiddenException("Account not linked to Salesforce");
}
return mapping.sfAccountId;
}
/**
* Apply filters to cases
*/
private applyFilters(cases: SupportCase[], filters?: SupportCaseFilter): SupportCase[] {
if (!filters) {
return cases;
}
const search = filters.search?.toLowerCase().trim(); const search = filters.search?.toLowerCase().trim();
return cases.filter(supportCase => { return cases.filter(supportCase => {
if (filters.status && supportCase.status !== filters.status) { if (filters.status && supportCase.status !== filters.status) {
return false; return false;
@ -116,7 +164,8 @@ export class SupportService {
return false; return false;
} }
if (search) { if (search) {
const haystack = `${supportCase.subject} ${supportCase.description} ${supportCase.id}`.toLowerCase(); const haystack =
`${supportCase.subject} ${supportCase.description} ${supportCase.caseNumber}`.toLowerCase();
if (!haystack.includes(search)) { if (!haystack.includes(search)) {
return false; return false;
} }
@ -125,6 +174,9 @@ export class SupportService {
}); });
} }
/**
* Build summary statistics for cases
*/
private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] { private buildSummary(cases: SupportCase[]): SupportCaseList["summary"] {
const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length; const open = cases.filter(c => OPEN_STATUSES.includes(c.status)).length;
const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length; const highPriority = cases.filter(c => HIGH_PRIORITIES.includes(c.priority)).length;

View File

@ -0,0 +1,11 @@
import { SupportCaseDetailView } from "@/features/support";
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function SupportCaseDetailPage({ params }: PageProps) {
const { id } = await params;
return <SupportCaseDetailView caseId={id} />;
}

View File

@ -0,0 +1,12 @@
import { SupportHomeView } from "@/features/support";
import { AgentforceWidget } from "@/components";
export default function SupportPage() {
return (
<>
<SupportHomeView />
<AgentforceWidget />
</>
);
}

View File

@ -3,7 +3,7 @@
import React from "react"; import React from "react";
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state"; import { ErrorState } from "@/components/atoms/error-state";
import { toUserMessage } from "@/lib/utils"; import { getErrorMessage } from "@/lib/utils";
interface AsyncBlockProps { interface AsyncBlockProps {
isLoading?: boolean; isLoading?: boolean;
@ -59,7 +59,7 @@ export function AsyncBlock({
return ( return (
<ErrorState <ErrorState
title={"Unable to load"} title={"Unable to load"}
message={toUserMessage(error)} message={getErrorMessage(error)}
variant={variant === "page" ? "page" : variant === "inline" ? "inline" : "card"} variant={variant === "page" ? "page" : variant === "inline" ? "inline" : "card"}
/> />
); );

View File

@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { ChatBubbleLeftRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
/**
* Agentforce Widget Configuration
*
* These values should be set in environment variables:
* - NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL: The Salesforce Messaging for Web script URL
* - NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID: The deployment ID
* - NEXT_PUBLIC_SF_ORG_ID: Your Salesforce org ID
*
* To get these values:
* 1. Go to Salesforce Setup > Messaging > Embedded Service Deployments
* 2. Create or select a deployment
* 3. Copy the deployment code snippet
* 4. Extract the URL and deployment ID
*/
interface AgentforceWidgetProps {
/**
* Whether to show the floating chat button
* If false, the widget will only be triggered programmatically
*/
showFloatingButton?: boolean;
}
declare global {
interface Window {
embeddedservice_bootstrap?: {
settings: {
language: string;
};
init: (
orgId: string,
deploymentName: string,
baseSiteURL: string,
options: {
scrt2URL: string;
}
) => void;
};
}
}
export function AgentforceWidget({ showFloatingButton = true }: AgentforceWidgetProps) {
const [isLoaded, setIsLoaded] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
// Configuration from environment variables
const scriptUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_URL;
const orgId = process.env.NEXT_PUBLIC_SF_ORG_ID;
const deploymentId = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_DEPLOYMENT_ID;
const baseSiteUrl = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SITE_URL;
const scrt2Url = process.env.NEXT_PUBLIC_SF_EMBEDDED_SERVICE_SCRT2_URL;
useEffect(() => {
// Skip if not configured
if (!scriptUrl || !orgId || !deploymentId || !baseSiteUrl || !scrt2Url) {
setError("Agentforce widget is not configured. Please set the required environment variables.");
return;
}
// Skip if already loaded
if (window.embeddedservice_bootstrap) {
setIsLoaded(true);
return;
}
// Load the Salesforce Messaging for Web script
const script = document.createElement("script");
script.src = scriptUrl;
script.async = true;
script.onload = () => {
try {
if (window.embeddedservice_bootstrap) {
window.embeddedservice_bootstrap.settings.language = "en";
window.embeddedservice_bootstrap.init(
orgId,
deploymentId,
baseSiteUrl,
{ scrt2URL: scrt2Url }
);
setIsLoaded(true);
}
} catch (err) {
setError("Failed to initialize Agentforce widget");
console.error("Agentforce init error:", err);
}
};
script.onerror = () => {
setError("Failed to load Agentforce widget script");
};
document.body.appendChild(script);
return () => {
// Cleanup is handled by Salesforce's script
};
}, [scriptUrl, orgId, deploymentId, baseSiteUrl, scrt2Url]);
// If not configured, show nothing or a placeholder
if (error) {
// In development, show the error; in production, fail silently
if (process.env.NODE_ENV === "development") {
return (
<div className="fixed bottom-4 right-4 max-w-xs bg-yellow-50 border border-yellow-200 rounded-lg p-3 shadow-lg z-50">
<p className="text-xs text-yellow-700">{error}</p>
</div>
);
}
return null;
}
// Show custom floating button only if the Salesforce widget hasn't loaded
// Once loaded, Salesforce's native button will appear
if (!isLoaded && showFloatingButton) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
aria-label={isOpen ? "Close chat" : "Open chat"}
>
{isOpen ? (
<XMarkIcon className="h-6 w-6" />
) : (
<ChatBubbleLeftRightIcon className="h-6 w-6" />
)}
</button>
{isOpen && (
<div className="absolute bottom-16 right-0 w-80 bg-white rounded-lg shadow-xl border border-gray-200">
<div className="p-4 border-b border-gray-200 bg-blue-600 rounded-t-lg">
<h3 className="text-lg font-medium text-white">AI Assistant</h3>
<p className="text-sm text-blue-100">Loading...</p>
</div>
<div className="p-4 flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
</div>
)}
</div>
);
}
// Once loaded, Salesforce handles the UI
return null;
}

View File

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

View File

@ -33,11 +33,11 @@ export const Header = memo(function Header({ onMenuClick, user, profileReady }:
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
href="/support/kb" href="/support"
prefetch prefetch
aria-label="Help" aria-label="Help"
className="hidden sm:inline-flex p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors" className="hidden sm:inline-flex p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
title="Help Center" title="Support Center"
> >
<QuestionMarkCircleIcon className="h-5 w-5" /> <QuestionMarkCircleIcon className="h-5 w-5" />
</Link> </Link>

View File

@ -48,7 +48,6 @@ export const baseNavigation: NavigationItem[] = [
children: [ children: [
{ name: "Cases", href: "/support/cases" }, { name: "Cases", href: "/support/cases" },
{ name: "New Case", href: "/support/new" }, { name: "New Case", href: "/support/new" },
{ name: "Knowledge Base", href: "/support/kb" },
], ],
}, },
{ {

View File

@ -4,3 +4,4 @@
*/ */
export { AppShell } from "./AppShell/AppShell"; export { AppShell } from "./AppShell/AppShell";
export { AgentforceWidget } from "./AgentforceWidget";

View File

@ -1,16 +1,14 @@
/**
* Link WHMCS Form - Account migration form using domain schema
*/
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button, Input, ErrorMessage } from "@/components/atoms"; import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "@/features/auth/hooks"; import { useWhmcsLink } from "@/features/auth/hooks";
import { import { linkWhmcsRequestSchema, type LinkWhmcsResponse } from "@customer-portal/domain/auth";
linkWhmcsRequestSchema,
type LinkWhmcsRequest,
type LinkWhmcsResponse,
} from "@customer-portal/domain/auth";
type LinkWhmcsFormData = LinkWhmcsRequest;
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
interface LinkWhmcsFormProps { interface LinkWhmcsFormProps {
@ -21,80 +19,60 @@ interface LinkWhmcsFormProps {
export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) { export function LinkWhmcsForm({ onTransferred, className = "" }: LinkWhmcsFormProps) {
const { linkWhmcs, loading, error, clearError } = useWhmcsLink(); const { linkWhmcs, loading, error, clearError } = useWhmcsLink();
const handleLink = useCallback( const form = useZodForm({
async (formData: LinkWhmcsFormData) => { schema: linkWhmcsRequestSchema,
initialValues: { email: "", password: "" },
onSubmit: async data => {
clearError(); clearError();
const payload: LinkWhmcsRequest = { const result = await linkWhmcs(data);
email: formData.email,
password: formData.password,
};
const result = await linkWhmcs(payload);
onTransferred?.(result); onTransferred?.(result);
}, },
[linkWhmcs, onTransferred, clearError]
);
const { values, errors, isSubmitting, setValue, handleSubmit } = useZodForm({
schema: linkWhmcsRequestSchema,
initialValues: {
email: "",
password: "",
},
onSubmit: handleLink,
}); });
const isLoading = form.isSubmitting || loading;
return ( return (
<div className={`w-full max-w-md mx-auto ${className}`}> <form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6"> <FormField label="Email Address" error={form.touched.email ? form.errors.email : undefined} required>
<div className="mb-6"> <Input
<h2 className="text-lg font-semibold text-gray-900 mb-2">Link Your WHMCS Account</h2> type="email"
<p className="text-sm text-gray-600"> value={form.values.email}
Enter your existing WHMCS credentials to link your account and migrate your data. onChange={e => form.setValue("email", e.target.value)}
</p> onBlur={() => form.setTouchedField("email")}
</div> placeholder="you@example.com"
disabled={isLoading}
autoComplete="email"
autoFocus
/>
</FormField>
<form onSubmit={event => void handleSubmit(event)} className="space-y-4"> <FormField label="Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField label="Email Address" error={errors.email} required> <Input
<Input type="password"
type="email" value={form.values.password}
value={values.email} onChange={e => form.setValue("password", e.target.value)}
onChange={e => setValue("email", e.target.value)} onBlur={() => form.setTouchedField("password")}
placeholder="Enter your WHMCS email" placeholder="Enter your legacy portal password"
disabled={isSubmitting || loading} disabled={isLoading}
className="w-full" autoComplete="current-password"
/> />
</FormField> </FormField>
<FormField label="Password" error={errors.password} required> {error && <ErrorMessage className="text-center">{error}</ErrorMessage>}
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
placeholder="Enter your WHMCS password"
disabled={isSubmitting || loading}
className="w-full"
/>
</FormField>
{error && <ErrorMessage className="text-center">{error}</ErrorMessage>} <Button
type="submit"
disabled={isLoading || !form.values.email || !form.values.password}
loading={isLoading}
loadingText="Verifying..."
className="w-full"
>
Transfer My Account
</Button>
<Button <p className="text-xs text-gray-500 text-center">
type="submit" Your credentials are encrypted and used only to verify your identity
disabled={isSubmitting || loading} </p>
loading={isSubmitting || loading} </form>
loadingText="Linking Account..."
className="w-full"
>
Link WHMCS Account
</Button>
</form>
<div className="mt-4 text-center">
<p className="text-xs text-gray-500">
Your credentials are used only to verify your identity and migrate your data securely.
</p>
</div>
</div>
</div>
); );
} }

View File

@ -1,6 +1,5 @@
/** /**
* Set Password Form Component * Set Password Form - Password creation after WHMCS migration
* Form for setting password after WHMCS account linking - migrated to use Zod validation
*/ */
"use client"; "use client";
@ -11,148 +10,133 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { useWhmcsLink } from "../../hooks/use-auth"; import { useWhmcsLink } from "../../hooks/use-auth";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { setPasswordRequestSchema } from "@customer-portal/domain/auth"; import {
setPasswordRequestSchema,
checkPasswordStrength,
getPasswordStrengthDisplay,
} from "@customer-portal/domain/auth";
import { z } from "zod"; import { z } from "zod";
// Extend domain schema with confirmPassword
const setPasswordFormSchema = setPasswordRequestSchema
.extend({ confirmPassword: z.string().min(1, "Please confirm your password") })
.refine(data => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
interface SetPasswordFormProps { interface SetPasswordFormProps {
email?: string; email?: string;
onSuccess?: () => void; onSuccess?: () => void;
onError?: (error: string) => void; onError?: (error: string) => void;
showLoginLink?: boolean;
className?: string; className?: string;
} }
export function SetPasswordForm({ export function SetPasswordForm({ email = "", onSuccess, onError, className = "" }: SetPasswordFormProps) {
email = "",
onSuccess,
onError,
showLoginLink = true,
className = "",
}: SetPasswordFormProps) {
const { setPassword, loading, error, clearError } = useWhmcsLink(); const { setPassword, loading, error, clearError } = useWhmcsLink();
/** const form = useZodForm({
* Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword
*
* Single source of truth: Domain layer defines validation rules
* Frontend only adds: confirmPassword field and password matching logic
*/
const setPasswordFormSchema = setPasswordRequestSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const form = useZodForm<SetPasswordFormValues>({
schema: setPasswordFormSchema, schema: setPasswordFormSchema,
initialValues: { initialValues: { email, password: "", confirmPassword: "" },
email, onSubmit: async data => {
password: "",
confirmPassword: "",
},
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
void _ignore;
clearError(); clearError();
try { try {
await setPassword(data.email, data.password); await setPassword(data.email, data.password);
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to set password"; onError?.(err instanceof Error ? err.message : "Failed to set password");
onError?.(errorMessage);
throw err; throw err;
} }
}, },
}); });
// Handle errors from auth hooks const { requirements, strength, isValid } = checkPasswordStrength(form.values.password);
const { label, colorClass } = getPasswordStrengthDisplay(strength);
const passwordsMatch = form.values.password === form.values.confirmPassword;
const isLoading = loading || form.isSubmitting;
const isEmailProvided = Boolean(email);
useEffect(() => { useEffect(() => {
if (error) { if (error) onError?.(error);
onError?.(error);
}
}, [error, onError]); }, [error, onError]);
// Update email when prop changes
useEffect(() => { useEffect(() => {
if (email && email !== form.values.email) { if (email && email !== form.values.email) form.setValue("email", email);
form.setValue("email", email);
}
}, [email, form]); }, [email, form]);
return ( return (
<div className={`space-y-6 ${className}`}> <form onSubmit={e => void form.handleSubmit(e)} className={`space-y-5 ${className}`}>
<div className="text-center"> <FormField label="Email Address" error={form.errors.email} required>
<h2 className="text-2xl font-bold text-gray-900">Set your password</h2> <Input
<p className="mt-2 text-sm text-gray-600"> type="email"
Create a password for your account to complete the setup. value={form.values.email}
</p> onChange={e => !isEmailProvided && form.setValue("email", e.target.value)}
</div> disabled={isLoading || isEmailProvided}
readOnly={isEmailProvided}
className={isEmailProvided ? "bg-gray-50 text-gray-600" : ""}
/>
{isEmailProvided && <p className="mt-1 text-xs text-gray-500">Verified during account transfer</p>}
</FormField>
<form onSubmit={event => void form.handleSubmit(event)} className="space-y-4"> <FormField label="New Password" error={form.touched.password ? form.errors.password : undefined} required>
<FormField label="Email address" error={form.errors.email} required> <Input
<Input type="password"
type="email" value={form.values.password}
placeholder="Enter your email" onChange={e => form.setValue("password", e.target.value)}
value={form.values.email} onBlur={() => form.setTouched("password", true)}
onChange={e => form.setValue("email", e.target.value)} placeholder="Create a secure password"
onBlur={() => form.setTouched("email", true)} disabled={isLoading}
disabled={loading || form.isSubmitting} autoComplete="new-password"
className={form.errors.email ? "border-red-300" : ""} autoFocus
/> />
</FormField> </FormField>
<FormField label="Password" error={form.errors.password} required> {form.values.password && (
<Input <div className="space-y-2">
type="password" <div className="flex items-center gap-2">
placeholder="Enter your password" <div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
value={form.values.password} <div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
onChange={e => form.setValue("password", e.target.value)} </div>
onBlur={() => form.setTouched("password", true)} <span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
disabled={loading || form.isSubmitting} </div>
className={form.errors.password ? "border-red-300" : ""} <div className="grid grid-cols-2 gap-1">
/> {requirements.map(r => (
</FormField> <div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>{r.met ? "✓" : "○"}</span>
<FormField label="Confirm password" error={form.errors.confirmPassword} required> <span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
<Input </div>
type="password" ))}
placeholder="Confirm your password" </div>
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
disabled={loading || form.isSubmitting}
className={form.errors.confirmPassword ? "border-red-300" : ""}
/>
</FormField>
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button
type="submit"
className="w-full"
disabled={loading || form.isSubmitting || !form.isValid}
loading={loading || form.isSubmitting}
>
Set password
</Button>
</form>
{showLoginLink && (
<div className="text-center">
<Link href="/login" className="text-sm text-blue-600 hover:text-blue-500 font-medium">
Back to login
</Link>
</div> </div>
)} )}
</div>
<FormField label="Confirm Password" error={form.touched.confirmPassword ? form.errors.confirmPassword : undefined} required>
<Input
type="password"
value={form.values.confirmPassword}
onChange={e => form.setValue("confirmPassword", e.target.value)}
onBlur={() => form.setTouched("confirmPassword", true)}
placeholder="Re-enter your password"
disabled={isLoading}
autoComplete="new-password"
/>
</FormField>
{form.values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
{(error || form.errors._form) && <ErrorMessage>{form.errors._form || error}</ErrorMessage>}
<Button type="submit" className="w-full" disabled={isLoading || !form.isValid} loading={isLoading}>
Set Password & Continue
</Button>
<div className="text-center">
<Link href="/auth/login" className="text-sm text-blue-600 hover:text-blue-500">Back to login</Link>
</div>
</form>
); );
} }

View File

@ -1,76 +0,0 @@
/**
* Account Step Component
* Email and password fields for signup
*/
"use client";
import { FormField } from "@/components/molecules/FormField/FormField";
interface AccountStepProps {
formData: {
email: string;
password: string;
confirmPassword: string;
};
errors: {
email?: string;
password?: string;
confirmPassword?: string;
};
onFieldChange: (field: string, value: string) => void;
onFieldBlur: (field: string) => void;
loading?: boolean;
}
export function AccountStep({
formData,
errors,
onFieldChange,
onFieldBlur,
loading = false,
}: AccountStepProps) {
return (
<div className="space-y-4">
<FormField
label="Email Address"
error={errors.email}
required
type="email"
value={formData.email}
onChange={e => onFieldChange("email", e.target.value)}
onBlur={() => onFieldBlur("email")}
placeholder="Enter your email address"
disabled={loading}
autoComplete="email"
autoFocus
/>
<FormField
label="Password"
error={errors.password}
required
type="password"
value={formData.password}
onChange={e => onFieldChange("password", e.target.value)}
onBlur={() => onFieldBlur("password")}
placeholder="Create a strong password"
disabled={loading}
autoComplete="new-password"
/>
<FormField
label="Confirm Password"
error={errors.confirmPassword}
required
type="password"
value={formData.confirmPassword}
onChange={e => onFieldChange("confirmPassword", e.target.value)}
onBlur={() => onFieldBlur("confirmPassword")}
placeholder="Confirm your password"
disabled={loading}
autoComplete="new-password"
/>
</div>
);
}

View File

@ -1,147 +0,0 @@
/**
* Address Step Component
* Address information fields for signup using Zod validation
*/
"use client";
import { useCallback } from "react";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
import type { Address } from "@customer-portal/domain/customer";
import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
interface AddressStepProps {
address: SignupFormValues["address"];
errors: FormErrors<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
onAddressChange: (address: SignupFormValues["address"]) => void;
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
}
export function AddressStep({
address,
errors,
touched,
onAddressChange,
setTouchedField,
}: AddressStepProps) {
// Use domain Address type directly - no type helpers needed
const updateAddressField = useCallback(
(field: keyof Address, value: string) => {
onAddressChange({ ...(address ?? {}), [field]: value });
},
[address, onAddressChange]
);
const handleCountryChange = useCallback(
(code: string) => {
const normalized = code || "";
onAddressChange({
...(address ?? {}),
country: normalized,
countryCode: normalized,
});
},
[address, onAddressChange]
);
const getFieldError = useCallback(
(field: keyof Address) => {
const fieldKey = `address.${field}`;
const isTouched = touched[fieldKey] ?? touched.address;
if (!isTouched) {
return undefined;
}
return errors[fieldKey] ?? errors[field] ?? errors.address;
},
[errors, touched]
);
const markTouched = useCallback(() => {
setTouchedField("address");
}, [setTouchedField]);
return (
<div className="space-y-6">
<FormField label="Street Address" error={getFieldError("address1")} required>
<Input
type="text"
value={address?.address1 || ""}
onChange={e => updateAddressField("address1", e.target.value)}
onBlur={markTouched}
placeholder="Enter your street address"
className="w-full"
/>
</FormField>
<FormField label="Address Line 2 (Optional)" error={getFieldError("address2")}>
<Input
type="text"
value={address?.address2 || ""}
onChange={e => updateAddressField("address2", e.target.value)}
onBlur={markTouched}
placeholder="Apartment, suite, etc."
className="w-full"
/>
</FormField>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="City" error={getFieldError("city")} required>
<Input
type="text"
value={address?.city || ""}
onChange={e => updateAddressField("city", e.target.value)}
onBlur={markTouched}
placeholder="Enter your city"
className="w-full"
/>
</FormField>
<FormField label="State/Province" error={getFieldError("state")} required>
<Input
type="text"
value={address?.state || ""}
onChange={e => updateAddressField("state", e.target.value)}
onBlur={markTouched}
placeholder="Enter your state/province"
className="w-full"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="Postal Code" error={getFieldError("postcode")} required>
<Input
type="text"
value={address?.postcode || ""}
onChange={e => updateAddressField("postcode", e.target.value)}
onBlur={markTouched}
placeholder="Enter your postal code"
className="w-full"
/>
</FormField>
<FormField label="Country" error={getFieldError("country")} required>
<select
value={address?.country || ""}
onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a country</option>
{COUNTRY_OPTIONS.map(country => (
<option key={country.code} value={country.code}>
{country.name}
</option>
))}
</select>
</FormField>
</div>
</div>
);
}

View File

@ -1,109 +0,0 @@
/**
* Password Step Component
* Password and security fields for signup using Zod validation
*/
"use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import type { UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
type PasswordStepProps = Pick<
UseZodFormReturn<SignupFormValues>,
"values" | "errors" | "touched" | "setValue" | "setTouchedField"
>;
export function PasswordStep({
values,
errors,
touched,
setValue,
setTouchedField,
}: PasswordStepProps) {
return (
<div className="space-y-6">
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
helperText="Password must be at least 8 characters long"
>
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Create a secure password"
className="w-full"
/>
</FormField>
<FormField
label="Confirm Password"
error={touched.confirmPassword ? errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={values.confirmPassword}
onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")}
placeholder="Confirm your password"
className="w-full"
/>
</FormField>
<div className="space-y-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="accept-terms"
name="accept-terms"
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="accept-terms" className="font-medium text-gray-700">
I accept the{" "}
<a href="/terms" className="text-blue-600 hover:text-blue-500">
Terms of Service
</a>{" "}
and{" "}
<a href="/privacy" className="text-blue-600 hover:text-blue-500">
Privacy Policy
</a>
</label>
{touched.acceptTerms && errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
</div>
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="marketing-consent"
name="marketing-consent"
type="checkbox"
checked={values.marketingConsent}
onChange={e => setValue("marketingConsent", e.target.checked)}
onBlur={() => setTouchedField("marketingConsent")}
className="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="marketing-consent" className="font-medium text-gray-700">
I would like to receive marketing communications and updates
</label>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,108 +0,0 @@
/**
* Personal Step Component
* Personal information fields for signup using Zod validation
*/
"use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import type { FormErrors, FormTouched, UseZodFormReturn } from "@customer-portal/validation";
import type { SignupFormValues } from "./SignupForm";
interface PersonalStepProps {
values: SignupFormValues;
errors: FormErrors<SignupFormValues>;
touched: FormTouched<SignupFormValues>;
setValue: UseZodFormReturn<SignupFormValues>["setValue"];
setTouchedField: UseZodFormReturn<SignupFormValues>["setTouchedField"];
}
export function PersonalStep({
values,
errors,
touched,
setValue,
setTouchedField,
}: PersonalStepProps) {
const getError = (field: keyof SignupFormValues) => {
return touched[field as string] ? errors[field as string] : undefined;
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required>
<Input
type="text"
value={values.firstName}
onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
placeholder="Enter your first name"
className="w-full"
/>
</FormField>
<FormField label="Last Name" error={getError("lastName")} required>
<Input
type="text"
value={values.lastName}
onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
placeholder="Enter your last name"
className="w-full"
/>
</FormField>
</div>
<FormField label="Email Address" error={getError("email")} required>
<Input
type="email"
value={values.email}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="Enter your email address"
className="w-full"
/>
</FormField>
<FormField label="Phone Number" error={getError("phone")} required>
<Input
type="tel"
value={values.phone || ""}
onChange={e => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
className="w-full"
/>
</FormField>
<FormField
label="Customer Number"
error={getError("sfNumber")}
required
helperText="Your existing customer number (minimum 6 characters)"
>
<Input
type="text"
value={values.sfNumber}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="Enter your customer number"
className="w-full"
/>
</FormField>
<FormField label="Company (Optional)" error={getError("company")}>
<Input
type="text"
value={values.company || ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
className="w-full"
/>
</FormField>
</div>
);
}

View File

@ -1,85 +0,0 @@
/**
* Preferences Step Component
* Terms acceptance and marketing preferences
*/
"use client";
import Link from "next/link";
interface PreferencesStepProps {
formData: {
acceptTerms: boolean;
marketingConsent: boolean;
};
errors: {
acceptTerms?: string;
};
onFieldChange: (field: string, value: boolean) => void;
onFieldBlur: (field: string) => void;
loading?: boolean;
}
export function PreferencesStep({
formData,
errors,
onFieldChange,
onFieldBlur,
loading = false,
}: PreferencesStepProps) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="acceptTerms"
checked={formData.acceptTerms}
onChange={e => onFieldChange("acceptTerms", e.target.checked)}
onBlur={() => onFieldBlur("acceptTerms")}
disabled={loading}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1">
<label htmlFor="acceptTerms" className="text-sm text-gray-900">
I accept the{" "}
<Link href="/terms" className="text-blue-600 hover:text-blue-500 underline">
Terms and Conditions
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-blue-600 hover:text-blue-500 underline">
Privacy Policy
</Link>
<span className="text-red-500 ml-1">*</span>
</label>
{errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
</div>
<div className="flex items-start space-x-3">
<input
type="checkbox"
id="marketingConsent"
checked={formData.marketingConsent}
onChange={e => onFieldChange("marketingConsent", e.target.checked)}
disabled={loading}
className="mt-1 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="marketingConsent" className="text-sm text-gray-900">
I would like to receive marketing communications and product updates
</label>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">Almost done!</h4>
<p className="text-sm text-blue-700">
By clicking &quot;Create Account&quot;, you&apos;ll be able to access your dashboard and
start using our services immediately.
</p>
</div>
</div>
);
}

View File

@ -1,275 +1,156 @@
/** /**
* Signup Form Component * Signup Form - Multi-step signup using domain schemas
* Multi-step signup form using Zod validation
*/ */
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import { ErrorMessage } from "@/components/atoms"; import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth"; import { useSignup } from "../../hooks/use-auth";
import { import {
type SignupRequest,
signupInputSchema, signupInputSchema,
buildSignupRequest, buildSignupRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { addressFormSchema } from "@customer-portal/domain/customer";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { z } from "zod"; import { z } from "zod";
import { MultiStepForm, type FormStep } from "./MultiStepForm"; import { MultiStepForm } from "./MultiStepForm";
import { AddressStep } from "./AddressStep"; import { AccountStep } from "./steps/AccountStep";
import { PasswordStep } from "./PasswordStep"; import { AddressStep } from "./steps/AddressStep";
import { PersonalStep } from "./PersonalStep"; import { PasswordStep } from "./steps/PasswordStep";
import { ReviewStep } from "./steps/ReviewStep";
import { getCountryCodeByName } from "@/lib/constants/countries"; import { getCountryCodeByName } from "@/lib/constants/countries";
interface SignupFormProps { // Extend domain schema with confirmPassword for frontend
onSuccess?: () => void; const signupFormSchema = signupInputSchema
onError?: (error: string) => void;
showLoginLink?: boolean;
className?: string;
}
/**
* Frontend form schema - extends domain signupInputSchema with UI-specific fields
*
* Single source of truth: Domain layer (signupInputSchema) defines all validation rules
* Frontend only adds: confirmPassword field and password matching logic
*/
export const signupFormSchema = signupInputSchema
.extend({ .extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
address: addressFormSchema,
}) })
.refine(data => data.acceptTerms === true, { .refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions", message: "You must accept the terms and conditions",
path: ["acceptTerms"], path: ["acceptTerms"],
}) })
.superRefine((data, ctx) => { .refine(data => data.password === data.confirmPassword, {
if (data.password !== data.confirmPassword) { message: "Passwords do not match",
ctx.addIssue({ path: ["confirmPassword"],
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Passwords do not match",
});
}
}); });
export type SignupFormValues = z.infer<typeof signupFormSchema>; type SignupFormData = z.infer<typeof signupFormSchema>;
export function SignupForm({ interface SignupFormProps {
onSuccess, onSuccess?: () => void;
onError, onError?: (error: string) => void;
showLoginLink = true, className?: string;
className = "", }
}: SignupFormProps) {
const STEPS = [
{ key: "account", title: "Account Details", description: "Your contact information" },
{ key: "address", title: "Service Address", description: "Where to deliver your SIM" },
{ key: "password", title: "Create Password", description: "Secure your account" },
{ key: "review", title: "Review & Accept", description: "Confirm your details" },
] as const;
export function SignupForm({ onSuccess, onError, className = "" }: SignupFormProps) {
const { signup, loading, error, clearError } = useSignup(); const { signup, loading, error, clearError } = useSignup();
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [step, setStep] = useState(0);
const handleSignup = useCallback( const form = useZodForm<SignupFormData>({
async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
void _confirm;
clearError();
try {
const normalizeCountryCode = (value?: string) => {
if (!value) return "";
if (value.length === 2) return value.toUpperCase();
return getCountryCodeByName(value) ?? value;
};
const normalizedAddress = formData.address
? (() => {
const countryValue = formData.address.country || formData.address.countryCode || "";
const normalizedCountry = normalizeCountryCode(countryValue);
return {
...formData.address,
country: normalizedCountry,
countryCode: normalizedCountry,
};
})()
: undefined;
const request: SignupRequest = buildSignupRequest({
...formData,
...(normalizedAddress ? { address: normalizedAddress } : {}),
});
await signup(request);
onSuccess?.();
} catch (err) {
const message = err instanceof Error ? err.message : "Signup failed";
onError?.(message);
throw err; // Re-throw to let useZodForm handle the error state
}
},
[signup, onSuccess, onError, clearError]
);
const {
values,
errors,
touched,
isSubmitting,
setValue,
setTouchedField,
handleSubmit,
validate,
} = useZodForm<SignupFormValues>({
schema: signupFormSchema, schema: signupFormSchema,
initialValues: { initialValues: {
email: "", sfNumber: "",
password: "",
confirmPassword: "",
firstName: "", firstName: "",
lastName: "", lastName: "",
company: "", email: "",
phone: "", phone: "",
sfNumber: "", company: "",
address: { address: { address1: "", address2: "", city: "", state: "", postcode: "", country: "", countryCode: "" },
address1: "", password: "",
address2: "", confirmPassword: "",
city: "",
state: "",
postcode: "",
country: "",
countryCode: "",
},
nationality: "",
dateOfBirth: "",
gender: "male" as const,
acceptTerms: false, acceptTerms: false,
marketingConsent: false, marketingConsent: false,
}, },
onSubmit: handleSignup, onSubmit: async data => {
clearError();
try {
const normalizedAddress = {
...data.address,
country: getCountryCodeByName(data.address.country) ?? data.address.country,
countryCode: getCountryCodeByName(data.address.countryCode) ?? data.address.countryCode,
};
const request = buildSignupRequest({ ...data, address: normalizedAddress });
await signup(request);
onSuccess?.();
} catch (err) {
onError?.(err instanceof Error ? err.message : "Signup failed");
throw err;
}
},
}); });
// Handle step change with validation const isLastStep = step === STEPS.length - 1;
const handleStepChange = useCallback((stepIndex: number) => {
setCurrentStepIndex(stepIndex);
}, []);
// Step field definitions (memoized for performance) const handleNext = useCallback(() => {
const stepFields = useMemo( form.validate();
() => ({ if (isLastStep) {
0: ["firstName", "lastName", "email", "phone"] as const, void form.handleSubmit();
1: ["address"] as const, } else {
2: ["password", "confirmPassword"] as const, setStep(s => s + 1);
3: ["sfNumber", "acceptTerms"] as const, }
}), }, [form, isLastStep]);
[]
);
// Validate specific step fields (optimized) // Wrap form methods to have generic types for step components
const validateStep = useCallback( const formProps = {
(stepIndex: number): boolean => { values: form.values,
const fields = stepFields[stepIndex as keyof typeof stepFields] || []; errors: form.errors,
touched: form.touched,
setValue: (field: string, value: unknown) => form.setValue(field as keyof SignupFormData, value as never),
setTouchedField: (field: string) => form.setTouchedField(field as keyof SignupFormData),
};
// Mark fields as touched and check for errors const stepContent = [
fields.forEach(field => setTouchedField(field)); <AccountStep key="account" form={formProps} />,
<AddressStep key="address" form={formProps} />,
// Use the validate function to get current validation state <PasswordStep key="password" form={formProps} />,
return validate() || !fields.some(field => Boolean(errors[String(field)])); <ReviewStep key="review" form={formProps} />,
},
[stepFields, setTouchedField, validate, errors]
);
const steps: FormStep[] = [
{
key: "personal",
title: "Personal Information",
description: "Tell us about yourself",
content: (
<PersonalStep
values={values}
errors={errors}
touched={touched}
setValue={setValue}
setTouchedField={setTouchedField}
/>
),
},
{
key: "address",
title: "Address",
description: "Where should we send your SIM?",
content: (
<AddressStep
address={values.address}
errors={errors}
touched={touched}
onAddressChange={address => setValue("address", address)}
setTouchedField={setTouchedField}
/>
),
},
{
key: "security",
title: "Security",
description: "Create a secure password",
content: (
<PasswordStep
values={values}
errors={errors}
touched={touched}
setValue={setValue}
setTouchedField={setTouchedField}
/>
),
},
]; ];
const currentStepFields = stepFields[currentStepIndex as keyof typeof stepFields] ?? []; const steps = STEPS.map((s, i) => ({
const canProceed = ...s,
currentStepIndex === steps.length - 1 content: stepContent[i],
? true }));
: currentStepFields.every(field => !errors[String(field)]);
return ( return (
<div className={`w-full max-w-2xl mx-auto ${className}`}> <div className={`w-full max-w-2xl mx-auto ${className}`}>
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6"> <div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Create Your Account</h1>
<p className="text-gray-600">
Join thousands of customers enjoying reliable connectivity
</p>
</div>
<MultiStepForm <MultiStepForm
steps={steps} steps={steps}
currentStep={currentStepIndex} currentStep={step}
onStepChange={handleStepChange} onNext={handleNext}
onNext={() => { onPrevious={() => setStep(s => Math.max(0, s - 1))}
if (validateStep(currentStepIndex)) { isLastStep={isLastStep}
if (currentStepIndex < steps.length - 1) { isSubmitting={form.isSubmitting || loading}
setCurrentStepIndex(currentStepIndex + 1); canProceed={true}
} else {
void handleSubmit();
}
}
}}
onPrevious={() => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
}}
isLastStep={currentStepIndex === steps.length - 1}
isSubmitting={isSubmitting || loading}
canProceed={canProceed}
/> />
{error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>} {error && <ErrorMessage className="mt-4 text-center">{error}</ErrorMessage>}
{showLoginLink && ( <div className="mt-6 text-center border-t border-gray-100 pt-6 space-y-2">
<div className="mt-6 text-center"> <p className="text-sm text-gray-600">
<p className="text-sm text-gray-600"> Already have an account?{" "}
Already have an account?{" "} <Link href="/auth/login" className="font-medium text-blue-600 hover:text-blue-500">
<Link Sign in
href="/auth/login" </Link>
className="font-medium text-blue-600 hover:text-blue-500 transition-colors" </p>
> <p className="text-sm text-gray-600">
Sign in Existing customer?{" "}
</Link> <Link href="/auth/link-whmcs" className="font-medium text-blue-600 hover:text-blue-500">
</p> Migrate your account
</div> </Link>
)} </p>
</div>
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,3 @@
export { SignupForm } from "./SignupForm"; export { SignupForm } from "./SignupForm";
export { MultiStepForm } from "./MultiStepForm"; export { MultiStepForm } from "./MultiStepForm";
export { AccountStep } from "./AccountStep"; export * from "./steps";
export { PersonalStep } from "./PersonalStep";
export { AddressStep } from "./AddressStep";
export { PreferencesStep } from "./PreferencesStep";

View File

@ -0,0 +1,98 @@
/**
* Account Step - Customer number and contact info
*/
"use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
interface AccountStepProps {
form: {
values: { sfNumber: string; firstName: string; lastName: string; email: string; phone: string; company?: string };
errors: Record<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
setTouchedField: (field: string) => void;
};
}
export function AccountStep({ form }: AccountStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
const getError = (field: string) => (touched[field] ? errors[field] : undefined);
return (
<div className="space-y-5">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<FormField
label="Customer Number"
error={getError("sfNumber")}
required
helperText="Your Assist Solutions customer number"
>
<Input
value={values.sfNumber}
onChange={e => setValue("sfNumber", e.target.value)}
onBlur={() => setTouchedField("sfNumber")}
placeholder="e.g., AST-123456"
className="w-full bg-white"
autoFocus
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="First Name" error={getError("firstName")} required>
<Input
value={values.firstName}
onChange={e => setValue("firstName", e.target.value)}
onBlur={() => setTouchedField("firstName")}
placeholder="Enter your first name"
autoComplete="given-name"
/>
</FormField>
<FormField label="Last Name" error={getError("lastName")} required>
<Input
value={values.lastName}
onChange={e => setValue("lastName", e.target.value)}
onBlur={() => setTouchedField("lastName")}
placeholder="Enter your last name"
autoComplete="family-name"
/>
</FormField>
</div>
<FormField label="Email Address" error={getError("email")} required>
<Input
type="email"
value={values.email}
onChange={e => setValue("email", e.target.value)}
onBlur={() => setTouchedField("email")}
placeholder="you@example.com"
autoComplete="email"
/>
</FormField>
<FormField label="Phone Number" error={getError("phone")} required>
<Input
type="tel"
value={values.phone}
onChange={e => setValue("phone", e.target.value)}
onBlur={() => setTouchedField("phone")}
placeholder="+81 XX-XXXX-XXXX"
autoComplete="tel"
/>
</FormField>
<FormField label="Company" error={getError("company")} helperText="Optional">
<Input
value={values.company ?? ""}
onChange={e => setValue("company", e.target.value)}
onBlur={() => setTouchedField("company")}
placeholder="Enter your company name"
autoComplete="organization"
/>
</FormField>
</div>
);
}

View File

@ -0,0 +1,125 @@
/**
* Address Step - Service address
*/
"use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { COUNTRY_OPTIONS } from "@/lib/constants/countries";
interface AddressData {
address1: string;
address2?: string;
city: string;
state: string;
postcode: string;
country: string;
countryCode?: string;
}
interface AddressStepProps {
form: {
values: { address: AddressData };
errors: Record<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
setTouchedField: (field: string) => void;
};
}
export function AddressStep({ form }: AddressStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
const address = values.address;
const getError = (field: string) => {
const key = `address.${field}`;
return touched[key] || touched.address ? (errors[key] ?? errors[field]) : undefined;
};
const updateAddress = (field: keyof AddressData, value: string) => {
setValue("address", { ...address, [field]: value });
};
const handleCountryChange = (code: string) => {
setValue("address", { ...address, country: code, countryCode: code });
};
const markTouched = () => setTouchedField("address");
return (
<div className="space-y-5">
<FormField label="Street Address" error={getError("address1")} required>
<Input
value={address.address1}
onChange={e => updateAddress("address1", e.target.value)}
onBlur={markTouched}
placeholder="123 Main Street"
autoComplete="address-line1"
autoFocus
/>
</FormField>
<FormField label="Address Line 2" error={getError("address2")} helperText="Optional">
<Input
value={address.address2 ?? ""}
onChange={e => updateAddress("address2", e.target.value)}
onBlur={markTouched}
placeholder="Apartment, suite, etc."
autoComplete="address-line2"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="City" error={getError("city")} required>
<Input
value={address.city}
onChange={e => updateAddress("city", e.target.value)}
onBlur={markTouched}
placeholder="Tokyo"
autoComplete="address-level2"
/>
</FormField>
<FormField label="State / Prefecture" error={getError("state")} required>
<Input
value={address.state}
onChange={e => updateAddress("state", e.target.value)}
onBlur={markTouched}
placeholder="Tokyo"
autoComplete="address-level1"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Postal Code" error={getError("postcode")} required>
<Input
value={address.postcode}
onChange={e => updateAddress("postcode", e.target.value)}
onBlur={markTouched}
placeholder="100-0001"
autoComplete="postal-code"
/>
</FormField>
<FormField label="Country" error={getError("country")} required>
<select
value={address.country}
onChange={e => handleCountryChange(e.target.value)}
onBlur={markTouched}
className="block w-full h-10 px-3 py-2 border border-gray-300 rounded-md shadow-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoComplete="country"
>
<option value="" disabled>Select country</option>
{COUNTRY_OPTIONS.map(c => (
<option key={c.code} value={c.code}>{c.name}</option>
))}
</select>
</FormField>
</div>
<p className="text-sm text-gray-500">
This address will be used for shipping SIM cards and other deliveries.
</p>
</div>
);
}

View File

@ -0,0 +1,87 @@
/**
* Password Step - Password creation with strength indicator
*/
"use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField";
import { checkPasswordStrength, getPasswordStrengthDisplay } from "@customer-portal/domain/auth";
interface PasswordStepProps {
form: {
values: { password: string; confirmPassword: string };
errors: Record<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
setTouchedField: (field: string) => void;
};
}
export function PasswordStep({ form }: PasswordStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
const { requirements, strength, isValid } = checkPasswordStrength(values.password);
const { label, colorClass } = getPasswordStrengthDisplay(strength);
const passwordsMatch = values.password === values.confirmPassword;
return (
<div className="space-y-6">
<FormField
label="Password"
error={touched.password ? errors.password : undefined}
required
>
<Input
type="password"
value={values.password}
onChange={e => setValue("password", e.target.value)}
onBlur={() => setTouchedField("password")}
placeholder="Create a secure password"
autoComplete="new-password"
/>
</FormField>
{values.password && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full transition-all ${colorClass}`} style={{ width: `${strength}%` }} />
</div>
<span className={`text-xs font-medium ${isValid ? "text-green-600" : "text-gray-500"}`}>{label}</span>
</div>
<div className="grid grid-cols-2 gap-1">
{requirements.map(r => (
<div key={r.key} className="flex items-center gap-1.5 text-xs">
<span className={r.met ? "text-green-500" : "text-gray-300"}>
{r.met ? "✓" : "○"}
</span>
<span className={r.met ? "text-green-700" : "text-gray-500"}>{r.label}</span>
</div>
))}
</div>
</div>
)}
<FormField
label="Confirm Password"
error={touched.confirmPassword ? errors.confirmPassword : undefined}
required
>
<Input
type="password"
value={values.confirmPassword}
onChange={e => setValue("confirmPassword", e.target.value)}
onBlur={() => setTouchedField("confirmPassword")}
placeholder="Re-enter your password"
autoComplete="new-password"
/>
</FormField>
{values.confirmPassword && (
<p className={`text-sm ${passwordsMatch ? "text-green-600" : "text-red-600"}`}>
{passwordsMatch ? "✓ Passwords match" : "✗ Passwords do not match"}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,107 @@
/**
* Review Step - Summary and terms acceptance
*/
"use client";
import Link from "next/link";
interface ReviewStepProps {
form: {
values: {
firstName: string;
lastName: string;
email: string;
phone: string;
sfNumber: string;
address: { address1: string; city: string; state: string; postcode: string };
acceptTerms: boolean;
marketingConsent?: boolean;
};
errors: Record<string, string | undefined>;
touched: Record<string, boolean | undefined>;
setValue: (field: string, value: unknown) => void;
setTouchedField: (field: string) => void;
};
}
export function ReviewStep({ form }: ReviewStepProps) {
const { values, errors, touched, setValue, setTouchedField } = form;
return (
<div className="space-y-6">
{/* Summary */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h4 className="text-sm font-medium text-gray-900 mb-3">Account Summary</h4>
<dl className="grid grid-cols-1 gap-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-500">Name</dt>
<dd className="text-gray-900 font-medium">{values.firstName} {values.lastName}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Email</dt>
<dd className="text-gray-900 font-medium">{values.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Phone</dt>
<dd className="text-gray-900 font-medium">{values.phone}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-500">Customer Number</dt>
<dd className="text-gray-900 font-medium">{values.sfNumber}</dd>
</div>
{values.address?.address1 && (
<div className="flex justify-between">
<dt className="text-gray-500">Address</dt>
<dd className="text-gray-900 font-medium text-right">
{values.address.address1}<br />
{values.address.city}, {values.address.state} {values.address.postcode}
</dd>
</div>
)}
</dl>
</div>
{/* Terms */}
<div className="space-y-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.acceptTerms}
onChange={e => setValue("acceptTerms", e.target.checked)}
onBlur={() => setTouchedField("acceptTerms")}
className="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
I accept the{" "}
<Link href="/terms" className="text-blue-600 hover:underline" target="_blank">Terms of Service</Link>
{" "}and{" "}
<Link href="/privacy" className="text-blue-600 hover:underline" target="_blank">Privacy Policy</Link>
<span className="text-red-500">*</span>
</span>
</label>
{touched.acceptTerms && errors.acceptTerms && (
<p className="text-sm text-red-600 ml-7">{errors.acceptTerms}</p>
)}
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={values.marketingConsent ?? false}
onChange={e => setValue("marketingConsent", e.target.checked)}
className="mt-0.5 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">
Send me marketing communications and product updates
<span className="block text-xs text-gray-500">You can unsubscribe anytime</span>
</span>
</label>
</div>
{/* Ready message */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-700">
By clicking &quot;Create Account&quot;, your account will be created and you can start managing your services.
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { AccountStep } from "./AccountStep";
export { AddressStep } from "./AddressStep";
export { PasswordStep } from "./PasswordStep";
export { ReviewStep } from "./ReviewStep";

View File

@ -6,7 +6,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { getNullableData } from "@/lib/api/response-helpers"; import { getNullableData } from "@/lib/api/response-helpers";
import { getErrorInfo } from "@/lib/utils/error-handling"; import { parseError } from "@/lib/utils/error-handling";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { import {
authResponseSchema, authResponseSchema,
@ -88,8 +88,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
applyAuthResponse(parsed.data); applyAuthResponse(parsed.data);
} catch (error) { } catch (error) {
logger.error("Failed to refresh session", error); logger.error("Failed to refresh session", error);
const errorInfo = getErrorInfo(error); const parsed = parseError(error);
const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const); const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const);
await get().logout({ reason }); await get().logout({ reason });
throw error; throw error;
} }
@ -144,8 +144,8 @@ export const useAuthStore = create<AuthState>()((set, get) => {
} }
applyAuthResponse(parsed.data, true); // Keep loading for redirect applyAuthResponse(parsed.data, true); // Keep loading for redirect
} catch (error) { } catch (error) {
const errorInfo = getErrorInfo(error); const parsed = parseError(error);
set({ loading: false, error: errorInfo.message, isAuthenticated: false }); set({ loading: false, error: parsed.message, isAuthenticated: false });
throw error; throw error;
} }
}, },
@ -340,9 +340,9 @@ export const useAuthStore = create<AuthState>()((set, get) => {
try { try {
await fetchProfile(); await fetchProfile();
} catch (error) { } catch (error) {
const errorInfo = getErrorInfo(error); const parsed = parseError(error);
if (errorInfo.shouldLogout) { if (parsed.shouldLogout) {
const reason = logoutReasonFromErrorCode(errorInfo.code) ?? ("session-expired" as const); const reason = logoutReasonFromErrorCode(parsed.code) ?? ("session-expired" as const);
await get().logout({ reason }); await get().logout({ reason });
return; return;
} }

View File

@ -1,79 +1,79 @@
/**
* Link WHMCS View - Account migration page
*/
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { LinkWhmcsForm } from "@/features/auth/components"; import { LinkWhmcsForm } from "@/features/auth/components";
import { MIGRATION_TRANSFER_ITEMS, MIGRATION_STEPS } from "@customer-portal/domain/auth";
export function LinkWhmcsView() { export function LinkWhmcsView() {
const router = useRouter(); const router = useRouter();
return ( return (
<AuthLayout <AuthLayout
title="Transfer your existing account" title="Transfer Your Account"
subtitle="Move your existing Assist Solutions account to our new portal" subtitle="Migrate your existing Assist Solutions account to our upgraded portal"
> >
<div className="space-y-8"> <div className="space-y-6">
<div className="p-5 bg-blue-50 border border-blue-200 rounded-xl"> {/* What transfers */}
<div className="flex"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex-shrink-0"> <p className="text-sm font-medium text-blue-800 mb-2">What gets transferred:</p>
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor"> <ul className="grid grid-cols-2 gap-1 text-sm text-blue-700">
<path {MIGRATION_TRANSFER_ITEMS.map((item, i) => (
fillRule="evenodd" <li key={i} className="flex items-center gap-1.5">
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" <span className="text-blue-500"></span> {item}
clipRule="evenodd" </li>
/> ))}
</svg>
</div>
<div className="ml-3 text-sm text-blue-700 space-y-2">
<p>
We&apos;ve upgraded our customer portal. Use your existing Assist Solutions
credentials to transfer your account and gain access to the new experience.
</p>
<ul className="list-disc list-inside space-y-1">
<li>All of your services and billing history will come with you</li>
<li>We&apos;ll guide you through creating a new, secure password afterwards</li>
<li>Your previous login credentials will no longer be needed once you transfer</li>
</ul>
</div>
</div>
</div>
<LinkWhmcsForm
onTransferred={result => {
const email = result.user.email;
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(email)}`);
return;
}
router.push("/dashboard");
}}
/>
<div className="space-y-2 text-sm text-gray-600">
<p>
Need a new account?{" "}
<Link href="/auth/signup" className="text-blue-600 hover:text-blue-500">
Create one here
</Link>
</p>
<p>
Already transferred your account?{" "}
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
Sign in here
</Link>
</p>
</div>
<div className="pt-6 border-t border-gray-200 space-y-2 text-sm text-gray-600">
<h3 className="font-medium text-gray-900">How the transfer works</h3>
<ul className="list-disc list-inside space-y-1">
<li>Enter the email and password you use for the legacy portal</li>
<li>We verify your account and ask you to set a new secure password</li>
<li>All existing subscriptions, invoices, and tickets stay connected</li>
<li>Need help? Contact support and we&apos;ll guide you through it</li>
</ul> </ul>
</div> </div>
{/* Form */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-1">Enter Legacy Portal Credentials</h2>
<p className="text-sm text-gray-600 mb-5">Use your previous Assist Solutions portal email and password.</p>
<LinkWhmcsForm
onTransferred={result => {
if (result.needsPasswordSet) {
router.push(`/auth/set-password?email=${encodeURIComponent(result.user.email)}`);
} else {
router.push("/dashboard");
}
}}
/>
</div>
{/* Links */}
<div className="flex justify-center gap-6 text-sm">
<span className="text-gray-600">
New customer? <Link href="/auth/signup" className="text-blue-600 hover:underline">Create account</Link>
</span>
<span className="text-gray-600">
Already transferred? <Link href="/auth/login" className="text-blue-600 hover:underline">Sign in</Link>
</span>
</div>
{/* Steps */}
<div className="border-t pt-6">
<h3 className="text-sm font-semibold text-gray-900 mb-3">How it works</h3>
<ol className="space-y-2">
{MIGRATION_STEPS.map((step, i) => (
<li key={i} className="flex items-start gap-3 text-sm">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 text-blue-600 text-xs flex items-center justify-center font-medium">
{i + 1}
</span>
<span className="text-gray-600">{step}</span>
</li>
))}
</ol>
</div>
<p className="text-center text-sm text-gray-500">
Need help? <Link href="/support" className="text-blue-600 hover:underline">Contact support</Link>
</p>
</div> </div>
</AuthLayout> </AuthLayout>
); );

View File

@ -11,20 +11,10 @@ export function SignupView() {
return ( return (
<> <>
<AuthLayout <AuthLayout
title="Create your portal account" title="Create Your Account"
subtitle="Verify your details and set up secure access in a few guided steps" subtitle="Set up your portal access in a few simple steps"
> >
<div className="space-y-8"> <SignupForm />
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
<h2 className="text-sm font-semibold text-blue-800 mb-3">What you&apos;ll need</h2>
<ul className="text-sm text-blue-700 space-y-2 list-disc list-inside">
<li>Your Assist Solutions customer number</li>
<li>Primary contact details and service address</li>
<li>A secure password that meets our enhanced requirements</li>
</ul>
</div>
<SignupForm />
</div>
</AuthLayout> </AuthLayout>
{/* Full-page loading overlay during authentication */} {/* Full-page loading overlay during authentication */}

View File

@ -21,7 +21,7 @@ export type OrderItem = CatalogProductBase & {
itemClass?: string; itemClass?: string;
}; };
export interface OrderConfiguration { export interface OrderSummaryConfiguration {
label: string; label: string;
value: string; value: string;
important?: boolean; important?: boolean;
@ -45,7 +45,7 @@ export interface EnhancedOrderSummaryProps {
planDescription?: string; planDescription?: string;
// Configuration details // Configuration details
configurations?: OrderConfiguration[]; configurations?: OrderSummaryConfiguration[];
// Additional information // Additional information
infoLines?: string[]; infoLines?: string[];

View File

@ -35,7 +35,7 @@ export type {
export type { export type {
EnhancedOrderSummaryProps, EnhancedOrderSummaryProps,
OrderItem, OrderItem,
OrderConfiguration, OrderSummaryConfiguration,
OrderTotals, OrderTotals,
} from "./base/EnhancedOrderSummary"; } from "./base/EnhancedOrderSummary";
export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep"; export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep";

View File

@ -10,6 +10,7 @@ import type {
SimCatalogProduct, SimCatalogProduct,
VpnCatalogProduct, VpnCatalogProduct,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { calculateSavingsPercentage } from "@customer-portal/domain/catalog";
type CatalogProduct = type CatalogProduct =
| InternetPlanCatalogItem | InternetPlanCatalogItem
@ -49,8 +50,6 @@ export function isProductRecommended(product: CatalogProduct): boolean {
/** /**
* Calculate savings percentage (if applicable) * Calculate savings percentage (if applicable)
* Re-exported from domain for backward compatibility
*/ */
export function calculateSavings(originalPrice: number, currentPrice: number): number { export const calculateSavings = calculateSavingsPercentage;
if (originalPrice <= currentPrice) return 0;
return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
}

View File

@ -3,35 +3,15 @@
* These are UI-specific formatting helpers, not business logic * These are UI-specific formatting helpers, not business logic
*/ */
import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import {
getCatalogProductPriceDisplay,
type CatalogProductBase,
type CatalogPriceInfo,
} from "@customer-portal/domain/catalog";
export interface PriceInfo { // Re-export domain type for compatibility
display: string; export type PriceInfo = CatalogPriceInfo;
monthly: number | null;
oneTime: number | null;
currency: string;
}
export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null { export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null {
const monthlyPrice = item.monthlyPrice ?? null; return getCatalogProductPriceDisplay(item);
const oneTimePrice = item.oneTimePrice ?? null;
const currency = "JPY";
if (monthlyPrice === null && oneTimePrice === null) {
return null;
}
let display = "";
if (monthlyPrice !== null && monthlyPrice > 0) {
display = `¥${monthlyPrice.toLocaleString()}/month`;
} else if (oneTimePrice !== null && oneTimePrice > 0) {
display = `¥${oneTimePrice.toLocaleString()} (one-time)`;
}
return {
display,
monthly: monthlyPrice,
oneTime: oneTimePrice,
currency,
};
} }

View File

@ -18,6 +18,7 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr
import { import {
ORDER_TYPE, ORDER_TYPE,
orderWithSkuValidationSchema, orderWithSkuValidationSchema,
prepareOrderFromCart,
type CheckoutCart, type CheckoutCart,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service"; import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
@ -150,31 +151,15 @@ export function useCheckout() {
// Debug logging to check cart contents // Debug logging to check cart contents
console.log("[DEBUG] Cart data:", cart); console.log("[DEBUG] Cart data:", cart);
console.log("[DEBUG] Cart items:", cart.items); console.log("[DEBUG] Cart items:", cart.items);
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus);
if (uniqueSkus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
// Validate cart before submission // Validate cart before submission
await checkoutService.validateCart(cart); await checkoutService.validateCart(cart);
const orderData = { // Use domain helper to prepare order data
orderType, // This encapsulates SKU extraction and payload formatting
skus: uniqueSkus, const orderData = prepareOrderFromCart(cart, orderType);
...(Object.keys(cart.configuration).length > 0
? { configurations: cart.configuration } console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus);
: {}),
};
const currentUserId = useAuthStore.getState().user?.id; const currentUserId = useAuthStore.getState().user?.id;
if (currentUserId) { if (currentUserId) {

View File

@ -6,58 +6,26 @@
import { import {
invoiceActivityMetadataSchema, invoiceActivityMetadataSchema,
serviceActivityMetadataSchema, serviceActivityMetadataSchema,
Activity, type Activity,
ActivityFilter, // Re-export business logic from domain
ActivityFilterConfig, ACTIVITY_FILTERS,
filterActivities,
isActivityClickable,
generateDashboardTasks,
type DashboardTask,
type DashboardTaskSummary,
} from "@customer-portal/domain/dashboard"; } from "@customer-portal/domain/dashboard";
import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit"; import { formatCurrency as formatCurrencyUtil } from "@customer-portal/domain/toolkit";
/** // Re-export domain business logic for backward compatibility
* Activity filter configurations export {
*/ ACTIVITY_FILTERS,
export const ACTIVITY_FILTERS: ActivityFilterConfig[] = [ filterActivities,
{ key: "all", label: "All" }, isActivityClickable,
{ generateDashboardTasks,
key: "billing", type DashboardTask,
label: "Billing", type DashboardTaskSummary,
types: ["invoice_created", "invoice_paid"], };
},
{
key: "orders",
label: "Orders",
types: ["service_activated"],
},
{
key: "support",
label: "Support",
types: ["case_created", "case_closed"],
},
];
/**
* Filter activities by type
*/
export function filterActivities(activities: Activity[], filter: ActivityFilter): Activity[] {
if (filter === "all") {
return activities;
}
const filterConfig = ACTIVITY_FILTERS.find(f => f.key === filter);
if (!filterConfig?.types) {
return activities;
}
return activities.filter(activity => filterConfig.types!.includes(activity.type));
}
/**
* Check if an activity is clickable (navigable)
*/
export function isActivityClickable(activity: Activity): boolean {
const clickableTypes: Activity["type"][] = ["invoice_created", "invoice_paid"];
return clickableTypes.includes(activity.type) && !!activity.relatedId;
}
/** /**
* Get navigation path for an activity * Get navigation path for an activity
@ -165,39 +133,6 @@ export function truncateText(text: string, maxLength = 28): string {
return text.slice(0, Math.max(0, maxLength - 1)) + "…"; return text.slice(0, Math.max(0, maxLength - 1)) + "…";
} }
/**
* Generate dashboard task suggestions based on summary data
*/
export function generateDashboardTasks(summary: {
nextInvoice?: { id: number } | null;
stats?: { unpaidInvoices?: number; openCases?: number };
}): Array<{ label: string; href: string }> {
const tasks: Array<{ label: string; href: string }> = [];
if (summary.nextInvoice) {
tasks.push({
label: "Pay upcoming invoice",
href: "#attention",
});
}
if (summary.stats?.unpaidInvoices && summary.stats.unpaidInvoices > 0) {
tasks.push({
label: "Review unpaid invoices",
href: "/billing/invoices",
});
}
if (summary.stats?.openCases && summary.stats.openCases > 0) {
tasks.push({
label: "Check support cases",
href: "/support/cases",
});
}
return tasks;
}
/** /**
* Calculate dashboard loading progress * Calculate dashboard loading progress
*/ */

View File

@ -10,6 +10,10 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
XCircleIcon, XCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import type { SimDetails } from "@customer-portal/domain/sim";
// Re-export for backwards compatibility
export type { SimDetails };
// Inline formatPlanShort function // Inline formatPlanShort function
function formatPlanShort(planCode?: string): string { function formatPlanShort(planCode?: string): string {
@ -26,33 +30,6 @@ function formatPlanShort(planCode?: string): string {
return planCode; return planCode;
} }
export interface SimDetails {
account: string;
msisdn: string;
iccid?: string;
imsi?: string;
eid?: string;
planCode: string;
status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean;
hasSms: boolean;
remainingQuotaKb: number;
remainingQuotaMb: number;
startDate?: string;
ipv4?: string;
ipv6?: string;
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string;
pendingOperations?: Array<{
operation: string;
scheduledDate: string;
}>;
}
interface SimDetailsCardProps { interface SimDetailsCardProps {
simDetails: SimDetails; simDetails: SimDetails;
isLoading?: boolean; isLoading?: boolean;
@ -292,7 +269,7 @@ export function SimDetailsCard({
<div> <div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3> <h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`} {formatPlan(simDetails.planCode)} {`${simDetails.simType} SIM`}
</p> </p>
</div> </div>
</div> </div>
@ -321,12 +298,10 @@ export function SimDetailsCard({
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p> <p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
</div> </div>
{simDetails.simType === "physical" && ( <div>
<div> <label className="text-xs text-gray-500">ICCID</label>
<label className="text-xs text-gray-500">ICCID</label> <p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p> </div>
</div>
)}
{simDetails.eid && ( {simDetails.eid && (
<div> <div>
@ -342,10 +317,10 @@ export function SimDetailsCard({
</div> </div>
)} )}
{simDetails.startDate && ( {simDetails.activatedAt && (
<div> <div>
<label className="text-xs text-gray-500">Service Start Date</label> <label className="text-xs text-gray-500">Service Start Date</label>
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p> <p className="text-sm text-gray-900">{formatDate(simDetails.activatedAt)}</p>
</div> </div>
)} )}
</div> </div>
@ -368,37 +343,32 @@ export function SimDetailsCard({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="flex items-center"> <div className="flex items-center">
<SignalIcon <SignalIcon
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`} className={`h-4 w-4 mr-1 ${simDetails.voiceMailEnabled ? "text-green-500" : "text-gray-400"}`}
/> />
<span <span
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`} className={`text-sm ${simDetails.voiceMailEnabled ? "text-green-600" : "text-gray-500"}`}
> >
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"} Voicemail {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<DevicePhoneMobileIcon <DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`} className={`h-4 w-4 mr-1 ${simDetails.callWaitingEnabled ? "text-green-500" : "text-gray-400"}`}
/> />
<span <span
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`} className={`text-sm ${simDetails.callWaitingEnabled ? "text-green-600" : "text-gray-500"}`}
> >
SMS {simDetails.hasSms ? "Enabled" : "Disabled"} Call Waiting {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
</span> </span>
</div> </div>
</div> </div>
{(simDetails.ipv4 || simDetails.ipv6) && ( {simDetails.internationalRoamingEnabled && (
<div> <div className="flex items-center">
<label className="text-xs text-gray-500">IP Address</label> <WifiIcon className="h-4 w-4 mr-1 text-green-500" />
<div className="space-y-1"> <span className="text-sm text-green-600">
{simDetails.ipv4 && ( International Roaming Enabled
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p> </span>
)}
{simDetails.ipv6 && (
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
)}
</div>
</div> </div>
)} )}
</div> </div>
@ -406,21 +376,14 @@ export function SimDetailsCard({
)} )}
</div> </div>
{/* Pending Operations */} {/* Expiry Date */}
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && ( {simDetails.expiresAt && (
<div className="mt-6 pt-6 border-t border-gray-200"> <div className="mt-6 pt-6 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3"> <div className="flex items-center text-sm">
Pending Operations <ClockIcon className="h-4 w-4 text-amber-500 mr-2" />
</h4> <span className="text-amber-800">
<div className="bg-blue-50 rounded-lg p-4"> Expires on {formatDate(simDetails.expiresAt)}
{simDetails.pendingOperations.map((operation, index) => ( </span>
<div key={index} className="flex items-center text-sm">
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
<span className="text-blue-800">
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
</span>
</div>
))}
</div> </div>
</div> </div>
)} )}

View File

@ -232,10 +232,10 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</p> </p>
<button <button
onClick={handlePayInvoice} onClick={handlePayInvoice}
disabled={createSsoLink.isPending || latestInvoice.status === 'paid'} disabled={createSsoLink.isPending || latestInvoice.status === 'Paid'}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors" className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
> >
{latestInvoice.status === 'paid' ? 'PAID' : createSsoLink.isPending ? 'Loading...' : 'PAY'} {latestInvoice.status === 'Paid' ? 'PAID' : createSsoLink.isPending ? 'Loading...' : 'PAY'}
</button> </button>
</div> </div>
)} )}

View File

@ -4,6 +4,7 @@ import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
SimCancelRequest, SimCancelRequest,
SimReissueRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
// Types imported from domain - no duplication // Types imported from domain - no duplication

View File

@ -0,0 +1,28 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, queryKeys } from "@/lib/api";
import type { CreateCaseRequest, CreateCaseResponse } from "@customer-portal/domain/support";
export function useCreateCase() {
const queryClient = useQueryClient();
return useMutation<CreateCaseResponse, Error, CreateCaseRequest>({
mutationFn: async (data: CreateCaseRequest) => {
const response = await apiClient.POST<CreateCaseResponse>("/api/support/cases", {
body: data,
});
if (!response.data) {
throw new Error("Failed to create support case");
}
return response.data;
},
onSuccess: () => {
// Invalidate cases list to refetch
void queryClient.invalidateQueries({ queryKey: queryKeys.support.cases() });
},
});
}

View File

@ -0,0 +1,21 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { apiClient, getDataOrThrow, queryKeys } from "@/lib/api";
import type { SupportCase } from "@customer-portal/domain/support";
export function useSupportCase(caseId: string | undefined) {
const { isAuthenticated } = useAuthSession();
return useQuery<SupportCase>({
queryKey: queryKeys.support.case(caseId ?? ""),
queryFn: async () => {
const response = await apiClient.GET<SupportCase>(`/api/support/cases/${caseId}`);
return getDataOrThrow(response, "Failed to load support case");
},
enabled: isAuthenticated && !!caseId,
staleTime: 60 * 1000,
});
}

View File

@ -0,0 +1,152 @@
import type { ReactNode } from "react";
import {
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
ChatBubbleLeftRightIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import {
SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY,
type SupportCaseStatus,
type SupportCasePriority,
} from "@customer-portal/domain/support";
/**
* Status variant types for styling
*/
export type CaseStatusVariant = "success" | "info" | "warning" | "neutral" | "purple";
export type CasePriorityVariant = "high" | "medium" | "low" | "neutral";
/**
* Icon size options
*/
export type IconSize = "sm" | "md";
const ICON_SIZE_CLASSES: Record<IconSize, string> = {
sm: "h-5 w-5",
md: "h-6 w-6",
};
/**
* Status to icon mapping
*/
const STATUS_ICON_MAP: Record<string, (className: string) => ReactNode> = {
[SUPPORT_CASE_STATUS.RESOLVED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.CLOSED]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.VPN_PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.PENDING]: (cls) => <CheckCircleIcon className={`${cls} text-green-500`} />,
[SUPPORT_CASE_STATUS.IN_PROGRESS]: (cls) => <ClockIcon className={`${cls} text-blue-500`} />,
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: (cls) => <ExclamationTriangleIcon className={`${cls} text-amber-500`} />,
[SUPPORT_CASE_STATUS.NEW]: (cls) => <SparklesIcon className={`${cls} text-purple-500`} />,
};
/**
* Status to variant mapping
*/
const STATUS_VARIANT_MAP: Record<string, CaseStatusVariant> = {
[SUPPORT_CASE_STATUS.RESOLVED]: "success",
[SUPPORT_CASE_STATUS.CLOSED]: "success",
[SUPPORT_CASE_STATUS.VPN_PENDING]: "success",
[SUPPORT_CASE_STATUS.PENDING]: "success",
[SUPPORT_CASE_STATUS.IN_PROGRESS]: "info",
[SUPPORT_CASE_STATUS.AWAITING_APPROVAL]: "warning",
[SUPPORT_CASE_STATUS.NEW]: "purple",
};
/**
* Priority to variant mapping
*/
const PRIORITY_VARIANT_MAP: Record<string, CasePriorityVariant> = {
[SUPPORT_CASE_PRIORITY.HIGH]: "high",
[SUPPORT_CASE_PRIORITY.MEDIUM]: "medium",
[SUPPORT_CASE_PRIORITY.LOW]: "low",
};
/**
* Tailwind class mappings for status variants
*/
const STATUS_CLASSES: Record<CaseStatusVariant, string> = {
success: "text-green-700 bg-green-50",
info: "text-blue-700 bg-blue-50",
warning: "text-amber-700 bg-amber-50",
purple: "text-purple-700 bg-purple-50",
neutral: "text-gray-700 bg-gray-50",
};
/**
* Tailwind class mappings for status variants with border
*/
const STATUS_CLASSES_WITH_BORDER: Record<CaseStatusVariant, string> = {
success: "text-green-700 bg-green-50 border-green-200",
info: "text-blue-700 bg-blue-50 border-blue-200",
warning: "text-amber-700 bg-amber-50 border-amber-200",
purple: "text-purple-700 bg-purple-50 border-purple-200",
neutral: "text-gray-700 bg-gray-50 border-gray-200",
};
/**
* Tailwind class mappings for priority variants
*/
const PRIORITY_CLASSES: Record<CasePriorityVariant, string> = {
high: "text-red-700 bg-red-50",
medium: "text-amber-700 bg-amber-50",
low: "text-green-700 bg-green-50",
neutral: "text-gray-700 bg-gray-50",
};
/**
* Tailwind class mappings for priority variants with border
*/
const PRIORITY_CLASSES_WITH_BORDER: Record<CasePriorityVariant, string> = {
high: "text-red-700 bg-red-50 border-red-200",
medium: "text-amber-700 bg-amber-50 border-amber-200",
low: "text-green-700 bg-green-50 border-green-200",
neutral: "text-gray-700 bg-gray-50 border-gray-200",
};
/**
* Get the icon component for a case status
*/
export function getCaseStatusIcon(status: string, size: IconSize = "sm"): ReactNode {
const sizeClass = ICON_SIZE_CLASSES[size];
const iconFn = STATUS_ICON_MAP[status];
if (iconFn) {
return iconFn(sizeClass);
}
return <ChatBubbleLeftRightIcon className={`${sizeClass} text-gray-400`} />;
}
/**
* Get the variant type for a case status
*/
export function getCaseStatusVariant(status: string): CaseStatusVariant {
return STATUS_VARIANT_MAP[status] ?? "neutral";
}
/**
* Get Tailwind classes for a case status
*/
export function getCaseStatusClasses(status: string, withBorder = false): string {
const variant = getCaseStatusVariant(status);
return withBorder ? STATUS_CLASSES_WITH_BORDER[variant] : STATUS_CLASSES[variant];
}
/**
* Get the variant type for a case priority
*/
export function getCasePriorityVariant(priority: string): CasePriorityVariant {
return PRIORITY_VARIANT_MAP[priority] ?? "neutral";
}
/**
* Get Tailwind classes for a case priority
*/
export function getCasePriorityClasses(priority: string, withBorder = false): string {
const variant = getCasePriorityVariant(priority);
return withBorder ? PRIORITY_CLASSES_WITH_BORDER[variant] : PRIORITY_CLASSES[variant];
}

View File

@ -0,0 +1,2 @@
export * from "./case-presenters";

View File

@ -4,41 +4,47 @@ import { useState, type FormEvent } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
ArrowLeftIcon,
PaperAirplaneIcon, PaperAirplaneIcon,
ExclamationCircleIcon, SparklesIcon,
InformationCircleIcon, ChatBubbleLeftRightIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { logger } from "@/lib/logger"; import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { useCreateCase } from "@/features/support/hooks/useCreateCase";
import {
SUPPORT_CASE_PRIORITY,
type SupportCasePriority,
} from "@customer-portal/domain/support";
export function NewSupportCaseView() { export function NewSupportCaseView() {
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false); const createCaseMutation = useCreateCase();
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
subject: "", subject: "",
category: "Technical", priority: SUPPORT_CASE_PRIORITY.MEDIUM as SupportCasePriority,
priority: "Medium",
description: "", description: "",
}); });
const handleSubmit = (event: FormEvent) => { const handleSubmit = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setIsSubmitting(true); setError(null);
// Mock submission - would normally send to API try {
void (async () => { await createCaseMutation.mutateAsync({
try { subject: formData.subject.trim(),
await new Promise(resolve => setTimeout(resolve, 2000)); description: formData.description.trim(),
priority: formData.priority,
});
// Redirect to cases list with success message router.push("/support/cases?created=true");
router.push("/support/cases?created=true"); } catch (err) {
} catch (error) { setError(err instanceof Error ? err.message : "Failed to create support case");
logger.error("Error creating case", error); }
} finally {
setIsSubmitting(false);
}
})();
}; };
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
@ -50,198 +56,194 @@ export function NewSupportCaseView() {
const isFormValid = formData.subject.trim() && formData.description.trim(); const isFormValid = formData.subject.trim() && formData.description.trim();
const priorityOptions = [
{ value: SUPPORT_CASE_PRIORITY.LOW, label: "Low - General question" },
{ value: SUPPORT_CASE_PRIORITY.MEDIUM, label: "Medium - Issue affecting work" },
{ value: SUPPORT_CASE_PRIORITY.HIGH, label: "High - Urgent / Service disruption" },
];
return ( return (
<div className="py-6"> <PageLayout
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8"> icon={<TicketIconSolid />}
{/* Header */} title="Create Support Case"
<div className="mb-8"> description="Get help from our support team"
<div className="flex items-center space-x-4 mb-4"> breadcrumbs={[
<button { label: "Support", href: "/support" },
onClick={() => router.back()} { label: "Create Case" },
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700" ]}
> >
<ArrowLeftIcon className="h-4 w-4 mr-1" /> {/* AI Chat Suggestion */}
Back to Support <AnimatedCard className="overflow-hidden" variant="highlighted">
</button> <div className="bg-gradient-to-r from-blue-600 to-blue-700 p-5">
</div> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Create Support Case</h1>
<p className="mt-1 text-sm text-gray-600">Get help from our support team</p>
</div>
</div>
{/* Help Tips */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<InformationCircleIcon className="h-5 w-5 text-blue-400" /> <div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center backdrop-blur-sm">
</div> <SparklesIcon className="h-6 w-6 text-white" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">Before creating a case</h3>
<div className="mt-2 text-sm text-blue-700">
<ul className="list-disc list-inside space-y-1">
<li>Check our knowledge base for common solutions</li>
<li>Include relevant error messages or screenshots</li>
<li>Provide detailed steps to reproduce the issue</li>
<li>Mention your service or subscription if applicable</li>
</ul>
</div> </div>
</div> </div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-white mb-1">
Try our AI Assistant first
</h3>
<p className="text-blue-100 text-sm">
Get instant answers to common questions. If the AI can&apos;t help, it will create
a case for you automatically.
</p>
</div>
<Button
className="w-full sm:w-auto bg-white text-blue-600 hover:bg-blue-50"
leftIcon={<ChatBubbleLeftRightIcon className="h-5 w-5" />}
>
Start Chat
</Button>
</div> </div>
</div> </div>
</AnimatedCard>
{/* Form */} {/* Error Message */}
<div className="bg-white shadow rounded-lg"> {error && (
<form onSubmit={handleSubmit} className="p-6 space-y-6"> <AlertBanner variant="error" title="Error creating case" elevated>
{/* Subject */} {error}
<div> </AlertBanner>
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-2"> )}
Subject *
</label>
<input
type="text"
id="subject"
value={formData.subject}
onChange={event => handleInputChange("subject", event.target.value)}
placeholder="Brief description of your issue"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Category and Priority */} {/* Form */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <AnimatedCard variant="static">
<div> <form onSubmit={handleSubmit} className="p-6 space-y-6">
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2"> {/* Subject */}
Category <div>
</label> <label htmlFor="subject" className="block text-sm font-medium text-gray-900 mb-2">
<select Subject <span className="text-red-500">*</span>
id="category" </label>
value={formData.category} <input
onChange={event => handleInputChange("category", event.target.value)} type="text"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" id="subject"
> value={formData.subject}
<option value="Technical">Technical Support</option> onChange={event => handleInputChange("subject", event.target.value)}
<option value="Billing">Billing Question</option> placeholder="Brief description of your issue"
<option value="General">General Inquiry</option> className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
<option value="Feature Request">Feature Request</option> maxLength={255}
<option value="Bug Report">Bug Report</option> required
</select> />
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
id="priority"
value={formData.priority}
onChange={event => handleInputChange("priority", event.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="Low">Low - General question</option>
<option value="Medium">Medium - Issue affecting work</option>
<option value="High">High - Service disruption</option>
<option value="Critical">Critical - Complete outage</option>
</select>
</div>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description *
</label>
<textarea
id="description"
rows={6}
value={formData.description}
onChange={event => handleInputChange("description", event.target.value)}
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
<p className="mt-2 text-xs text-gray-500">
The more details you provide, the faster we can help you.
</p>
</div>
{/* Priority Warning */}
{formData.priority === "Critical" && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Critical Priority Selected</h3>
<div className="mt-2 text-sm text-red-700">
<p>
Critical priority should only be used for complete service outages. For
urgent issues that aren&apos;t complete outages, please use High priority.
</p>
</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-6 border-t border-gray-200">
<button
type="button"
onClick={() => router.back()}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={!isFormValid || isSubmitting}
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Creating Case...
</>
) : (
<>
<PaperAirplaneIcon className="h-4 w-4 mr-2" />
Create Case
</>
)}
</button>
</div>
</form>
</div>
{/* Additional Help */}
<div className="mt-8 bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Need immediate help?</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<h4 className="text-sm font-medium text-gray-900">Phone Support</h4>
<p className="text-sm text-gray-600 mt-1">
9:30-18:00 JST
<br />
<span className="font-medium text-blue-600">0120-660-470</span>
</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-900">Knowledge Base</h4>
<p className="text-sm text-gray-600 mt-1">
Search our help articles for quick solutions
<br />
<Link href="/support/kb" className="font-medium text-blue-600 hover:text-blue-500">
Browse Knowledge Base
</Link>
</p>
</div>
</div> </div>
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-900 mb-2">
Priority
</label>
<select
id="priority"
value={formData.priority}
onChange={event => handleInputChange("priority", event.target.value)}
className="block w-full sm:w-1/2 px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
>
{priorityOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-900 mb-2">
Description <span className="text-red-500">*</span>
</label>
<textarea
id="description"
rows={6}
value={formData.description}
onChange={event => handleInputChange("description", event.target.value)}
placeholder="Please provide a detailed description of your issue, including any error messages and steps to reproduce the problem..."
className="block w-full px-4 py-3 border border-gray-300 rounded-xl text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
required
/>
<p className="mt-2 text-xs text-gray-500">
The more details you provide, the faster we can help you.
</p>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 pt-4 border-t border-gray-100">
<Link
href="/support"
className="text-sm text-gray-600 hover:text-gray-900 font-medium"
>
Cancel
</Link>
<Button
type="submit"
disabled={!isFormValid || createCaseMutation.isPending}
loading={createCaseMutation.isPending}
loadingText="Creating Case..."
leftIcon={<PaperAirplaneIcon className="h-4 w-4" />}
size="lg"
>
Create Case
</Button>
</div>
</form>
</AnimatedCard>
{/* Contact Options */}
<AnimatedCard className="overflow-hidden" variant="static">
<div className="p-5 border-b border-gray-100">
<h3 className="text-base font-semibold text-gray-900">Need immediate assistance?</h3>
<p className="text-sm text-gray-500 mt-1">Contact us directly for urgent matters</p>
</div> </div>
</div> <div className="grid grid-cols-1 sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-gray-100">
</div> {/* Phone Support */}
<a
href="tel:0120660470"
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors"
>
<div className="w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 group-hover:text-emerald-600 transition-colors">
Phone Support
</p>
<p className="text-2xl font-bold text-gray-900 mt-0.5">0120-660-470</p>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full"></span>
Mon-Fri, 9:30-18:00 JST
</p>
</div>
</a>
{/* AI Chat */}
<button
type="button"
className="group flex items-center gap-4 p-5 hover:bg-gray-50 transition-colors text-left w-full"
>
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-105 transition-transform">
<SparklesIcon className="h-6 w-6 text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
AI Chat Assistant
</p>
<p className="text-lg font-semibold text-gray-900 mt-0.5">Get instant answers</p>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse"></span>
Available 24/7
</p>
</div>
<div className="flex-shrink-0">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</span>
</div>
</button>
</div>
</AnimatedCard>
</PageLayout>
); );
} }

View File

@ -0,0 +1,161 @@
"use client";
import {
CalendarIcon,
ClockIcon,
TagIcon,
ArrowLeftIcon,
} from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { useSupportCase } from "@/features/support/hooks/useSupportCase";
import {
getCaseStatusIcon,
getCaseStatusClasses,
getCasePriorityClasses,
} from "@/features/support/utils";
interface SupportCaseDetailViewProps {
caseId: string;
}
export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
const { data: supportCase, isLoading, error, refetch } = useSupportCase(caseId);
if (!isLoading && !supportCase && !error) {
return (
<PageLayout
icon={<TicketIconSolid />}
title="Case Not Found"
breadcrumbs={[
{ label: "Support", href: "/support" },
{ label: "Cases", href: "/support/cases" },
{ label: "Not Found" },
]}
>
<AlertBanner variant="error" title="Case not found">
The support case you&apos;re looking for could not be found or you don&apos;t have
permission to view it.
</AlertBanner>
</PageLayout>
);
}
return (
<PageLayout
icon={<TicketIconSolid />}
title={supportCase ? `Case #${supportCase.caseNumber}` : "Loading..."}
description={supportCase?.subject}
loading={isLoading}
error={error}
onRetry={() => void refetch()}
breadcrumbs={[
{ label: "Support", href: "/support" },
{ label: "Cases", href: "/support/cases" },
{ label: supportCase ? `#${supportCase.caseNumber}` : "..." },
]}
actions={
<Button
as="a"
href="/support/cases"
variant="outline"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
>
All Cases
</Button>
}
>
{supportCase && (
<div className="space-y-6">
{/* Header Card - Status & Key Info */}
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
{/* Top Section - Status and Priority */}
<div className="p-5 border-b border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getCaseStatusIcon(supportCase.status, "md")}
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 leading-snug">
{supportCase.subject}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
Case #{supportCase.caseNumber}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<span
className={`inline-flex items-center px-3 py-1 rounded-lg text-sm font-medium ${getCaseStatusClasses(supportCase.status)}`}
>
{supportCase.status}
</span>
<span
className={`inline-flex items-center px-3 py-1 rounded-lg text-sm font-medium ${getCasePriorityClasses(supportCase.priority)}`}
>
{supportCase.priority}
</span>
</div>
</div>
</div>
{/* Meta Info Row */}
<div className="px-5 py-3 bg-gray-50/50 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-600">
<CalendarIcon className="h-4 w-4 text-gray-400" />
<span>Created {format(new Date(supportCase.createdAt), "MMM d, yyyy")}</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<ClockIcon className="h-4 w-4 text-gray-400" />
<span>Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}</span>
</div>
{supportCase.category && (
<div className="flex items-center gap-2 text-gray-600">
<TagIcon className="h-4 w-4 text-gray-400" />
<span>{supportCase.category}</span>
</div>
)}
{supportCase.closedAt && (
<div className="flex items-center gap-2 text-green-600">
<span> Closed {format(new Date(supportCase.closedAt), "MMM d, yyyy")}</span>
</div>
)}
</div>
</div>
{/* Description */}
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
<div className="px-5 py-3 border-b border-gray-100">
<h3 className="text-sm font-semibold text-gray-900">Description</h3>
</div>
<div className="p-5">
<div className="prose prose-sm max-w-none text-gray-600">
<p className="whitespace-pre-wrap leading-relaxed m-0">{supportCase.description}</p>
</div>
</div>
</div>
{/* Help Text */}
<div className="rounded-lg bg-blue-50 border border-blue-100 px-4 py-3">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-700">
<p className="font-medium">Need to update this case?</p>
<p className="mt-0.5 text-blue-600">Reply via email and your response will be added to this case automatically.</p>
</div>
</div>
</div>
</div>
)}
</PageLayout>
);
}

View File

@ -1,32 +1,40 @@
"use client"; "use client";
import { useDeferredValue, useMemo, useState } from "react"; import { useDeferredValue, useMemo, useState } from "react";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { import {
ChatBubbleLeftRightIcon, ChatBubbleLeftRightIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
PlusIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ClockIcon, ClockIcon,
ChevronRightIcon, ChevronRightIcon,
CalendarIcon, TicketIcon,
UserIcon, XMarkIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { format } from "date-fns"; import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
import { formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms";
import { EmptyState, SearchEmptyState } from "@/components/atoms/empty-state";
import { useSupportCases } from "@/features/support/hooks/useSupportCases"; import { useSupportCases } from "@/features/support/hooks/useSupportCases";
import { import {
SUPPORT_CASE_PRIORITY, SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_STATUS, SUPPORT_CASE_STATUS,
type SupportCaseFilter, type SupportCaseFilter,
type SupportCasePriority,
type SupportCaseStatus,
} from "@customer-portal/domain/support"; } from "@customer-portal/domain/support";
import {
getCaseStatusIcon,
getCaseStatusClasses,
getCasePriorityClasses,
} from "@/features/support/utils";
export function SupportCasesView() { export function SupportCasesView() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<SupportCaseStatus | "all">("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const [priorityFilter, setPriorityFilter] = useState<SupportCasePriority | "all">("all"); const [priorityFilter, setPriorityFilter] = useState<string>("all");
const deferredSearchTerm = useDeferredValue(searchTerm); const deferredSearchTerm = useDeferredValue(searchTerm);
const queryFilters = useMemo(() => { const queryFilters = useMemo(() => {
@ -43,13 +51,15 @@ export function SupportCasesView() {
return nextFilters; return nextFilters;
}, [statusFilter, priorityFilter, deferredSearchTerm]); }, [statusFilter, priorityFilter, deferredSearchTerm]);
const { data, isLoading, error } = useSupportCases(queryFilters); const { data, isLoading, error, refetch } = useSupportCases(queryFilters);
const cases = data?.cases ?? []; const cases = data?.cases ?? [];
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 }; const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
const hasActiveFilters = statusFilter !== "all" || priorityFilter !== "all" || searchTerm.trim();
const statusFilterOptions = useMemo( const statusFilterOptions = useMemo(
() => [ () => [
{ value: "all" as const, label: "All Statuses" }, { value: "all", label: "All Statuses" },
...Object.values(SUPPORT_CASE_STATUS).map(status => ({ value: status, label: status })), ...Object.values(SUPPORT_CASE_STATUS).map(status => ({ value: status, label: status })),
], ],
[] []
@ -57,7 +67,7 @@ export function SupportCasesView() {
const priorityFilterOptions = useMemo( const priorityFilterOptions = useMemo(
() => [ () => [
{ value: "all" as const, label: "All Priorities" }, { value: "all", label: "All Priorities" },
...Object.values(SUPPORT_CASE_PRIORITY).map(priority => ({ ...Object.values(SUPPORT_CASE_PRIORITY).map(priority => ({
value: priority, value: priority,
label: priority, label: priority,
@ -66,302 +76,186 @@ export function SupportCasesView() {
[] []
); );
const getStatusIcon = (status: SupportCaseStatus) => { const clearFilters = () => {
switch (status) { setSearchTerm("");
case SUPPORT_CASE_STATUS.RESOLVED: setStatusFilter("all");
case SUPPORT_CASE_STATUS.CLOSED: setPriorityFilter("all");
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case SUPPORT_CASE_STATUS.IN_PROGRESS:
return <ClockIcon className="h-5 w-5 text-blue-500" />;
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case SUPPORT_CASE_STATUS.OPEN:
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
default:
return <ChatBubbleLeftRightIcon className="h-5 w-5 text-gray-500" />;
}
}; };
const getStatusColor = (status: SupportCaseStatus) => {
switch (status) {
case SUPPORT_CASE_STATUS.RESOLVED:
case SUPPORT_CASE_STATUS.CLOSED:
return "bg-green-100 text-green-800";
case SUPPORT_CASE_STATUS.IN_PROGRESS:
return "bg-blue-100 text-blue-800";
case SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER:
return "bg-yellow-100 text-yellow-800";
case SUPPORT_CASE_STATUS.OPEN:
return "bg-gray-100 text-gray-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getPriorityColor = (priority: SupportCasePriority) => {
switch (priority) {
case SUPPORT_CASE_PRIORITY.CRITICAL:
return "bg-red-100 text-red-800";
case SUPPORT_CASE_PRIORITY.HIGH:
return "bg-orange-100 text-orange-800";
case SUPPORT_CASE_PRIORITY.MEDIUM:
return "bg-yellow-100 text-yellow-800";
case SUPPORT_CASE_PRIORITY.LOW:
return "bg-green-100 text-green-800";
default:
return "bg-gray-100 text-gray-800";
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading support cases...</p>
</div>
</div>
);
}
return ( return (
<> <PageLayout
<div className="py-6"> icon={<ChatBubbleLeftRightIconSolid />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8"> title="Support Cases"
{/* Header */} description="Track and manage your support requests"
<div className="mb-8"> loading={isLoading}
<div className="flex items-center justify-between"> error={error}
<div> onRetry={() => void refetch()}
<h1 className="text-2xl font-bold text-gray-900">Support Cases</h1> breadcrumbs={[
<p className="mt-1 text-sm text-gray-600">Track and manage your support requests</p> { label: "Support", href: "/support" },
</div> { label: "Cases" },
<Link ]}
href="/support/new" actions={
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" <Button
> as="a"
<PlusIcon className="h-4 w-4 mr-2" /> href="/support/new"
New Case leftIcon={<TicketIcon className="h-4 w-4" />}
</Link> >
</div> New Case
</div> </Button>
}
{error && ( >
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800"> {/* Summary Strip */}
{error instanceof Error ? error.message : "Failed to load support cases"} <div className="flex flex-wrap items-center gap-6 px-1 text-sm">
</div> <div className="flex items-center gap-2">
)} <ChatBubbleLeftRightIcon className="h-4 w-4 text-gray-400" />
<span className="text-gray-600">Total</span>
{/* Stats Cards */} <span className="font-semibold text-gray-900">{summary.total}</span>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8"> </div>
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="flex items-center gap-2">
<div className="p-5"> <ClockIcon className="h-4 w-4 text-blue-500" />
<div className="flex items-center"> <span className="text-gray-600">Open</span>
<div className="flex-shrink-0"> <span className="font-semibold text-blue-600">{summary.open}</span>
<ChatBubbleLeftRightIcon className="h-6 w-6 text-gray-600" /> </div>
</div> {summary.highPriority > 0 && (
<div className="ml-5 w-0 flex-1"> <div className="flex items-center gap-2">
<dl> <ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
<dt className="text-sm font-medium text-gray-500 truncate">Total Cases</dt> <span className="text-gray-600">High Priority</span>
<dd className="text-lg font-medium text-gray-900">{summary.total}</dd> <span className="font-semibold text-amber-600">{summary.highPriority}</span>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<ClockIcon className="h-6 w-6 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Open Cases</dt>
<dd className="text-lg font-medium text-gray-900">{summary.open}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">High Priority</dt>
<dd className="text-lg font-medium text-gray-900">{summary.highPriority}</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-6 w-6 text-green-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Resolved</dt>
<dd className="text-lg font-medium text-gray-900">{summary.resolved}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white shadow rounded-lg mb-6">
<div className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="Search cases..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Status Filter */}
<div className="relative">
<select
value={statusFilter}
onChange={event =>
setStatusFilter(event.target.value as SupportCaseStatus | "all")
}
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{/* Priority Filter */}
<div className="relative">
<select
value={priorityFilter}
onChange={event =>
setPriorityFilter(event.target.value as SupportCasePriority | "all")
}
className="block w-full px-3 py-2 border border-gray-300 rounded-md leading-5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Cases List */}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{cases.map(supportCase => (
<li key={supportCase.id}>
<Link
href={`/support/cases/${supportCase.id}`}
className="block hover:bg-gray-50"
>
<div className="px-4 py-4 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 flex-1">
<div className="flex-shrink-0">{getStatusIcon(supportCase.status)}</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-3 mb-2">
<p className="text-sm font-medium text-blue-600 truncate">
#{supportCase.id} - {supportCase.subject}
</p>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(supportCase.status)}`}
>
{supportCase.status}
</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getPriorityColor(supportCase.priority)}`}
>
{supportCase.priority}
</span>
</div>
<p className="text-sm text-gray-500 mb-2 line-clamp-2">
{supportCase.description}
</p>
<div className="flex items-center space-x-6 text-xs text-gray-500">
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
Created: {format(new Date(supportCase.createdAt), "MMM d, yyyy")}
</div>
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
Updated: {format(new Date(supportCase.updatedAt), "MMM d, yyyy")}
</div>
{supportCase.assignedTo && (
<div className="flex items-center">
<UserIcon className="h-4 w-4 mr-1" />
{supportCase.assignedTo}
</div>
)}
<span className="capitalize bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs">
{supportCase.category}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<ChevronRightIcon className="h-5 w-5 text-gray-400" />
</div>
</div>
</div>
</Link>
</li>
))}
</ul>
{/* Empty State */}
{cases.length === 0 && (
<div className="text-center py-12">
<ChatBubbleLeftRightIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No support cases found</h3>
<p className="mt-1 text-sm text-gray-500">
{searchTerm || statusFilter !== "all" || priorityFilter !== "all"
? "Try adjusting your search or filter criteria"
: "Your support cases will appear here when you create them"}
</p>
{searchTerm === "" && statusFilter === "all" && priorityFilter === "all" && (
<div className="mt-6">
<Link
href="/support/new"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Your First Case
</Link>
</div>
)}
</div>
)}
</div> </div>
)}
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="text-gray-600">Resolved</span>
<span className="font-semibold text-green-600">{summary.resolved}</span>
</div> </div>
</div> </div>
</>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row gap-3">
{/* Search */}
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
placeholder="Search by case number or subject..."
value={searchTerm}
onChange={event => setSearchTerm(event.target.value)}
className="block w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm bg-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
/>
</div>
{/* Filters */}
<div className="flex gap-2">
<select
value={statusFilter}
onChange={event => setStatusFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
{statusFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<select
value={priorityFilter}
onChange={event => setPriorityFilter(event.target.value)}
className="appearance-none pl-3 pr-8 py-2 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
{priorityFilterOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="flex items-center gap-1 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<XMarkIcon className="h-4 w-4" />
<span className="hidden sm:inline">Clear</span>
</button>
)}
</div>
</div>
{/* Cases List */}
{cases.length > 0 ? (
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
{cases.map(supportCase => (
<div
key={supportCase.id}
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
>
{/* Status Icon */}
<div className="flex-shrink-0">
{getCaseStatusIcon(supportCase.status)}
</div>
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-blue-600">
#{supportCase.caseNumber}
</span>
<span className="text-sm text-gray-900 truncate">
{supportCase.subject}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex text-xs px-2 py-0.5 rounded font-medium ${getCaseStatusClasses(supportCase.status)}`}
>
{supportCase.status}
</span>
<span
className={`inline-flex text-xs px-2 py-0.5 rounded font-medium ${getCasePriorityClasses(supportCase.priority)}`}
>
{supportCase.priority}
</span>
{supportCase.category && (
<span className="text-xs text-gray-500">
{supportCase.category}
</span>
)}
</div>
</div>
{/* Timestamp */}
<div className="hidden sm:block text-right flex-shrink-0">
<p className="text-xs text-gray-400">
Updated {formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</p>
</div>
{/* Arrow */}
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
</div>
))}
</div>
) : hasActiveFilters ? (
<AnimatedCard className="p-8" variant="static">
<SearchEmptyState searchTerm={searchTerm || "filters"} onClearSearch={clearFilters} />
</AnimatedCard>
) : (
<AnimatedCard className="p-8" variant="static">
<EmptyState
icon={<TicketIcon className="h-12 w-12" />}
title="No support cases found"
description="You haven't created any support cases yet. Need help? Create a new case."
action={{
label: "Create Case",
onClick: () => router.push("/support/new"),
}}
/>
</AnimatedCard>
)}
</PageLayout>
); );
} }

View File

@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ChatBubbleLeftRightIcon,
SparklesIcon,
PlusIcon,
ChevronRightIcon,
TicketIcon,
ClockIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import { ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid } from "@heroicons/react/24/solid";
import { formatDistanceToNow } from "date-fns";
import { PageLayout } from "@/components/templates/PageLayout";
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms";
import { EmptyState } from "@/components/atoms/empty-state";
import { useSupportCases } from "@/features/support/hooks/useSupportCases";
import { getCaseStatusIcon, getCaseStatusClasses } from "@/features/support/utils";
export function SupportHomeView() {
const router = useRouter();
const { data, isLoading, error, refetch } = useSupportCases();
const recentCases = data?.cases?.slice(0, 5) ?? [];
const summary = data?.summary ?? { total: 0, open: 0, highPriority: 0, resolved: 0 };
return (
<PageLayout
icon={<ChatBubbleLeftRightIconSolid />}
title="Support Center"
description="Get help with your account and services"
loading={isLoading}
error={error}
onRetry={() => void refetch()}
>
{/* Quick Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* AI Assistant Card */}
<div className="bg-gradient-to-br from-blue-600 to-blue-700 rounded-xl p-5 text-white">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center flex-shrink-0">
<SparklesIcon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold mb-1">AI Assistant</h3>
<p className="text-sm text-blue-100 mb-4 leading-relaxed">
Get instant answers to common questions about your account.
</p>
<Button
size="sm"
className="bg-white text-blue-600 hover:bg-blue-50 shadow-sm"
>
Start Chat
</Button>
</div>
</div>
</div>
{/* Create Case Card */}
<div className="border border-gray-200 rounded-xl p-5 bg-white">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<PlusIcon className="h-5 w-5 text-gray-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 mb-1">Create Support Case</h3>
<p className="text-sm text-gray-500 mb-4 leading-relaxed">
Our team typically responds within 24 hours.
</p>
<Button
as="a"
href="/support/new"
size="sm"
variant="outline"
>
New Case
</Button>
</div>
</div>
</div>
</div>
{/* Cases Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<h3 className="text-lg font-semibold text-gray-900">Your Cases</h3>
{summary.total > 0 && (
<div className="flex items-center gap-3 text-sm">
<span className="flex items-center gap-1.5 text-gray-500">
<ClockIcon className="h-4 w-4 text-blue-500" />
<span className="font-medium text-blue-600">{summary.open}</span> open
</span>
<span className="flex items-center gap-1.5 text-gray-500">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="font-medium text-green-600">{summary.resolved}</span> resolved
</span>
</div>
)}
</div>
{summary.total > 0 && (
<Link
href="/support/cases"
className="text-sm text-blue-600 hover:text-blue-700 font-medium inline-flex items-center gap-1"
>
View all
<ChevronRightIcon className="h-4 w-4" />
</Link>
)}
</div>
{recentCases.length > 0 ? (
<div className="border border-gray-200 rounded-xl bg-white divide-y divide-gray-100 overflow-hidden">
{recentCases.map(supportCase => (
<div
key={supportCase.id}
onClick={() => router.push(`/support/cases/${supportCase.id}`)}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer transition-colors group"
>
<div className="flex-shrink-0">
{getCaseStatusIcon(supportCase.status)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-gray-900">
#{supportCase.caseNumber}
</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${getCaseStatusClasses(supportCase.status)}`}
>
{supportCase.status}
</span>
</div>
<p className="text-sm text-gray-600 truncate">{supportCase.subject}</p>
</div>
<div className="hidden sm:block text-xs text-gray-400 flex-shrink-0">
{formatDistanceToNow(new Date(supportCase.updatedAt), { addSuffix: true })}
</div>
<ChevronRightIcon className="h-4 w-4 text-gray-300 group-hover:text-gray-400 flex-shrink-0 transition-colors" />
</div>
))}
</div>
) : (
<AnimatedCard className="p-8" variant="static">
<EmptyState
icon={<TicketIcon className="h-12 w-12" />}
title="No support cases yet"
description="Need help? Start a chat with our AI assistant or create a support case."
action={{
label: "Create Case",
onClick: () => router.push("/support/new"),
}}
/>
</AnimatedCard>
)}
</div>
</PageLayout>
);
}

View File

@ -1,2 +1,4 @@
export * from "./NewSupportCaseView"; export * from "./NewSupportCaseView";
export * from "./SupportCasesView"; export * from "./SupportCasesView";
export * from "./SupportCaseDetailView";
export * from "./SupportHomeView";

View File

@ -15,6 +15,58 @@ export * from "./response-helpers";
import { createClient, ApiError } from "./runtime/client"; import { createClient, ApiError } from "./runtime/client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
/**
* Auth endpoints that should NOT trigger automatic logout on 401
* These are endpoints where 401 means "invalid credentials", not "session expired"
*/
const AUTH_ENDPOINTS = [
"/api/auth/login",
"/api/auth/signup",
"/api/auth/link-whmcs",
"/api/auth/set-password",
"/api/auth/reset-password",
"/api/auth/check-password-needed",
];
/**
* Check if a URL path is an auth endpoint
*/
function isAuthEndpoint(url: string): boolean {
try {
const urlPath = new URL(url).pathname;
return AUTH_ENDPOINTS.some(endpoint => urlPath.endsWith(endpoint));
} catch {
return AUTH_ENDPOINTS.some(endpoint => url.includes(endpoint));
}
}
/**
* Extract error message from API error body
* Handles both `{ message }` and `{ error: { message } }` formats
*/
function extractErrorMessage(body: unknown): string | null {
if (!body || typeof body !== "object") {
return null;
}
// Check for nested error.message format (standard API error response)
const bodyWithError = body as { error?: { message?: unknown } };
if (bodyWithError.error && typeof bodyWithError.error === "object") {
const errorMessage = bodyWithError.error.message;
if (typeof errorMessage === "string") {
return errorMessage;
}
}
// Check for top-level message
const bodyWithMessage = body as { message?: unknown };
if (typeof bodyWithMessage.message === "string") {
return bodyWithMessage.message;
}
return null;
}
/** /**
* Global error handler for API client * Global error handler for API client
* Handles authentication errors and triggers logout when needed * Handles authentication errors and triggers logout when needed
@ -23,7 +75,10 @@ async function handleApiError(response: Response): Promise<void> {
// Don't import useAuthStore at module level to avoid circular dependencies // Don't import useAuthStore at module level to avoid circular dependencies
// We'll handle auth errors by dispatching a custom event that the auth system can listen to // We'll handle auth errors by dispatching a custom event that the auth system can listen to
if (response.status === 401) { // Only dispatch logout event for 401s on non-auth endpoints
// Auth endpoints (login, signup, etc.) return 401 for invalid credentials,
// which should NOT trigger logout - just show the error message
if (response.status === 401 && !isAuthEndpoint(response.url)) {
logger.warn("Received 401 Unauthorized response - triggering logout"); logger.warn("Received 401 Unauthorized response - triggering logout");
// Dispatch a custom event that the auth system will listen to // Dispatch a custom event that the auth system will listen to
@ -45,11 +100,9 @@ async function handleApiError(response: Response): Promise<void> {
const contentType = cloned.headers.get("content-type"); const contentType = cloned.headers.get("content-type");
if (contentType?.includes("application/json")) { if (contentType?.includes("application/json")) {
body = await cloned.json(); body = await cloned.json();
if (body && typeof body === "object" && "message" in body) { const extractedMessage = extractErrorMessage(body);
const maybeMessage = (body as { message?: unknown }).message; if (extractedMessage) {
if (typeof maybeMessage === "string") { message = extractedMessage;
message = maybeMessage;
}
} }
} }
} catch { } catch {
@ -104,6 +157,7 @@ export const queryKeys = {
}, },
support: { support: {
cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const, cases: (params?: Record<string, unknown>) => ["support", "cases", params] as const,
case: (id: string) => ["support", "case", id] as const,
}, },
currency: { currency: {
default: () => ["currency", "default"] as const, default: () => ["currency", "default"] as const,

View File

@ -1,41 +0,0 @@
/**
* Error display utilities
* Converts errors to user-friendly messages
*/
import { isApiError } from "@/lib/api/runtime/client";
export function toUserMessage(error: unknown): string {
if (typeof error === "string") {
return error;
}
// Handle API errors with specific status codes
if (isApiError(error)) {
const status = error.response?.status;
const body = error.body as Record<string, unknown> | undefined;
// Rate limit error (429)
if (status === 429) {
return "Too many requests. Please wait a moment and try again.";
}
// Get message from error body
if (body && typeof body.error === "object") {
const errorObj = body.error as Record<string, unknown>;
if (typeof errorObj.message === "string") {
return errorObj.message;
}
}
if (body && typeof body.message === "string") {
return body.message;
}
}
if (error && typeof error === "object" && "message" in error) {
return String(error.message);
}
return "An unexpected error occurred";
}

View File

@ -1,256 +1,254 @@
/** /**
* Standardized Error Handling for Portal * Unified Error Handling for Portal
* Provides consistent error handling and user-friendly messages *
* Clean, simple error handling that uses shared error codes from domain package.
* Provides consistent error parsing and user-friendly messages.
*/ */
import { ApiError as ClientApiError } from "@/lib/api"; import { ApiError as ClientApiError, isApiError } from "@/lib/api";
import { apiErrorResponseSchema, type ApiErrorResponse } from "@customer-portal/domain/common"; import {
ErrorCode,
ErrorMessages,
ErrorMetadata,
matchErrorPattern,
type ErrorCodeType,
} from "@customer-portal/domain/common";
export type ApiErrorPayload = ApiErrorResponse; // ============================================================================
// Types
// ============================================================================
export interface ApiErrorInfo { export interface ParsedError {
code: string; code: ErrorCodeType;
message: string; message: string;
shouldLogout?: boolean; shouldLogout: boolean;
shouldRetry?: boolean; shouldRetry: boolean;
} }
// ============================================================================
// Error Parsing
// ============================================================================
/** /**
* Extract error information from various error types * Parse any error into a structured format with code and user-friendly message.
* This is the main entry point for error handling.
*/ */
export function getErrorInfo(error: unknown): ApiErrorInfo { export function parseError(error: unknown): ParsedError {
if (error instanceof ClientApiError) { // Handle API client errors
const info = parseClientApiError(error); if (isApiError(error)) {
if (info) { return parseApiError(error);
return info;
}
} }
// Handle fetch/network errors // Handle network/fetch errors
if (error instanceof Error) { if (error instanceof Error) {
if (error.name === "TypeError" && error.message.includes("fetch")) { return parseNativeError(error);
return { }
code: "NETWORK_ERROR",
message:
"Unable to connect to the server. Please check your internet connection and try again.",
shouldRetry: true,
};
}
if (error.name === "AbortError") {
return {
code: "REQUEST_TIMEOUT",
message: "The request timed out. Please try again.",
shouldRetry: true,
};
}
// Handle string errors
if (typeof error === "string") {
const code = matchErrorPattern(error);
const metadata = ErrorMetadata[code];
return { return {
code: "UNKNOWN_ERROR", code,
message: "An unexpected error occurred. Please try again.", message: error,
shouldRetry: true, shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry,
}; };
} }
// Fallback for unknown error types // Unknown error type
return { return {
code: "UNKNOWN_ERROR", code: ErrorCode.UNKNOWN,
message: "An unexpected error occurred. Please try again.", message: ErrorMessages[ErrorCode.UNKNOWN],
shouldLogout: false,
shouldRetry: true, shouldRetry: true,
}; };
} }
export function isApiErrorPayload(error: unknown): error is ApiErrorPayload {
const parsed = apiErrorResponseSchema.safeParse(error);
return parsed.success;
}
/** /**
* Determine if the user should be logged out for this error * Parse API client error
*/ */
function shouldLogoutForError(code: string): boolean { function parseApiError(error: ClientApiError): ParsedError {
const logoutCodes = ["TOKEN_REVOKED", "INVALID_REFRESH_TOKEN"]; const body = error.body;
return logoutCodes.includes(code);
}
/**
* Determine if the request should be retried for this error
*/
function shouldRetryForError(code: string): boolean {
const noRetryCodes = [
"INVALID_CREDENTIALS",
"FORBIDDEN",
"ADMIN_REQUIRED",
"ACCOUNT_LOCKED",
"VALIDATION_ERROR",
"ACCOUNT_ALREADY_LINKED",
"ACCOUNT_EXISTS",
"CUSTOMER_NOT_FOUND",
"INVALID_REQUEST",
];
return !noRetryCodes.includes(code);
}
/**
* Get a user-friendly error message for display in UI
*/
export function getUserFriendlyMessage(error: unknown): string {
const errorInfo = getErrorInfo(error);
return errorInfo.message;
}
/**
* Handle authentication errors consistently
*/
export function handleAuthError(error: unknown, logout: () => void | Promise<void>): boolean {
const errorInfo = getErrorInfo(error);
if (errorInfo.shouldLogout) {
void logout();
return true;
}
return false;
}
/**
* Create a standardized error for logging
*/
export function createErrorLog(
error: unknown,
context: string
): {
context: string;
code: string;
message: string;
timestamp: string;
} {
const errorInfo = getErrorInfo(error);
return {
context,
code: errorInfo.code,
message: errorInfo.message,
timestamp: new Date().toISOString(),
};
}
function parseClientApiError(error: ClientApiError): ApiErrorInfo | null {
const status = error.response?.status; const status = error.response?.status;
const parsedBody = parseRawErrorBody(error.body);
const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null; // Try to extract from standard API error response format
if (body && typeof body === "object") {
const bodyObj = body as Record<string, unknown>;
if (payloadInfo) { // Check for standard { success: false, error: { code, message } } format
return payloadInfo; if (
} bodyObj.success === false &&
bodyObj.error &&
typeof bodyObj.error === "object"
) {
const errorObj = bodyObj.error as Record<string, unknown>;
const code = typeof errorObj.code === "string" ? errorObj.code : undefined;
const message = typeof errorObj.message === "string" ? errorObj.message : undefined;
return { if (code && message) {
code: status ? httpStatusCodeToLabel(status) : "API_ERROR", const metadata = ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
message: error.message, return {
shouldLogout: status ? shouldLogoutForError(httpStatusCodeToLabel(status)) : false, code: code as ErrorCodeType,
shouldRetry: typeof status === "number" ? status >= 500 : true, message,
}; shouldLogout: metadata.shouldLogout,
} shouldRetry: metadata.shouldRetry,
};
}
}
function parseRawErrorBody(body: unknown): unknown { // Try extracting message from body
if (!body) { const extractedMessage = extractMessageFromBody(body);
return null; if (extractedMessage) {
} const code = matchErrorPattern(extractedMessage);
const metadata = ErrorMetadata[code];
if (typeof body === "string") { return {
try { code,
return JSON.parse(body); message: extractedMessage,
} catch { shouldLogout: metadata.shouldLogout,
return body; shouldRetry: metadata.shouldRetry,
};
} }
} }
return body; // Fall back to status code mapping
const code = mapStatusToErrorCode(status);
const metadata = ErrorMetadata[code];
return {
code,
message: error.message || ErrorMessages[code],
shouldLogout: metadata.shouldLogout,
shouldRetry: metadata.shouldRetry,
};
} }
function deriveInfoFromPayload(payload: unknown, status?: number): ApiErrorInfo | null { /**
const parsed = apiErrorResponseSchema.safeParse(payload); * Parse native JavaScript errors (network, timeout, etc.)
if (parsed.success) { */
const code = parsed.data.error.code; function parseNativeError(error: Error): ParsedError {
// Network errors
if (error.name === "TypeError" && error.message.includes("fetch")) {
return { return {
code, code: ErrorCode.NETWORK_ERROR,
message: parsed.data.error.message, message: ErrorMessages[ErrorCode.NETWORK_ERROR],
shouldLogout: shouldLogoutForError(code), shouldLogout: false,
shouldRetry: shouldRetryForError(code), shouldRetry: true,
}; };
} }
if (isGlobalErrorPayload(payload)) { // Timeout errors
const code = payload.code || payload.error || httpStatusCodeToLabel(status); if (error.name === "AbortError") {
const message = payload.message || "Request failed. Please try again.";
const derivedStatus = payload.statusCode ?? status;
return { return {
code, code: ErrorCode.TIMEOUT,
message, message: ErrorMessages[ErrorCode.TIMEOUT],
shouldLogout: shouldLogoutForError(code), shouldLogout: false,
shouldRetry: shouldRetry: true,
typeof derivedStatus === "number" ? derivedStatus >= 500 : shouldRetryForError(code),
}; };
} }
if ( // Try pattern matching on error message
typeof payload === "object" && const code = matchErrorPattern(error.message);
payload !== null && const metadata = ErrorMetadata[code];
"message" in payload && return {
typeof (payload as { message?: unknown }).message === "string" code,
) { message: code === ErrorCode.UNKNOWN ? error.message : ErrorMessages[code],
const payloadWithMessage = payload as { code?: unknown; message: string }; shouldLogout: metadata.shouldLogout,
const candidateCode = payloadWithMessage.code; shouldRetry: metadata.shouldRetry,
const code = typeof candidateCode === "string" ? candidateCode : httpStatusCodeToLabel(status); };
}
return { /**
code, * Extract error message from response body
message: payloadWithMessage.message, */
shouldLogout: shouldLogoutForError(code), function extractMessageFromBody(body: unknown): string | null {
shouldRetry: typeof status === "number" ? status >= 500 : shouldRetryForError(code), if (!body || typeof body !== "object") {
}; return null;
}
const bodyObj = body as Record<string, unknown>;
// Check nested error.message (standard format)
if (bodyObj.error && typeof bodyObj.error === "object") {
const errorObj = bodyObj.error as Record<string, unknown>;
if (typeof errorObj.message === "string") {
return errorObj.message;
}
}
// Check top-level message
if (typeof bodyObj.message === "string") {
return bodyObj.message;
} }
return null; return null;
} }
function isGlobalErrorPayload(payload: unknown): payload is { /**
success: false; * Map HTTP status code to error code
code?: string; */
message?: string; function mapStatusToErrorCode(status?: number): ErrorCodeType {
error?: string; if (!status) return ErrorCode.UNKNOWN;
statusCode?: number;
} {
return (
typeof payload === "object" &&
payload !== null &&
"success" in payload &&
(payload as { success?: unknown }).success === false &&
("code" in payload || "message" in payload || "error" in payload)
);
}
function httpStatusCodeToLabel(status?: number): string {
if (!status) {
return "API_ERROR";
}
switch (status) { switch (status) {
case 400: case 400:
return "BAD_REQUEST"; return ErrorCode.VALIDATION_FAILED;
case 401: case 401:
return "UNAUTHORIZED"; return ErrorCode.SESSION_EXPIRED;
case 403: case 403:
return "FORBIDDEN"; return ErrorCode.FORBIDDEN;
case 404: case 404:
return "NOT_FOUND"; return ErrorCode.NOT_FOUND;
case 409: case 409:
return "CONFLICT"; return ErrorCode.ACCOUNT_EXISTS;
case 422: case 429:
return "UNPROCESSABLE_ENTITY"; return ErrorCode.RATE_LIMITED;
case 503:
return ErrorCode.SERVICE_UNAVAILABLE;
default: default:
return status >= 500 ? "SERVER_ERROR" : `HTTP_${status}`; return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
} }
} }
// ============================================================================
// Convenience Functions
// ============================================================================
/**
* Get user-friendly error message from any error
*/
export function getErrorMessage(error: unknown): string {
return parseError(error).message;
}
/**
* Check if error should trigger logout
*/
export function shouldLogout(error: unknown): boolean {
return parseError(error).shouldLogout;
}
/**
* Check if error can be retried
*/
export function canRetry(error: unknown): boolean {
return parseError(error).shouldRetry;
}
/**
* Get error code from any error
*/
export function getErrorCode(error: unknown): ErrorCodeType {
return parseError(error).code;
}
// ============================================================================
// Re-exports from domain package for convenience
// ============================================================================
export {
ErrorCode,
ErrorMessages,
ErrorMetadata,
matchErrorPattern,
type ErrorCodeType,
} from "@customer-portal/domain/common";

View File

@ -1,2 +1,12 @@
export { cn } from "./cn"; export { cn } from "./cn";
export { toUserMessage } from "./error-display"; export {
parseError,
getErrorMessage,
shouldLogout,
canRetry,
getErrorCode,
ErrorCode,
ErrorMessages,
type ParsedError,
type ErrorCodeType,
} from "./error-handling";

View File

@ -0,0 +1,76 @@
/**
* Auth Domain - Form Utilities
*
* Business logic for password validation and strength checking.
* UI configurations (labels, placeholders) belong in the frontend.
*/
// ============================================================================
// Password Requirements (Business Logic)
// ============================================================================
/**
* Password requirements - single source of truth for validation rules.
* Used by passwordSchema in common/schema.ts and for UI display.
*/
export const PASSWORD_REQUIREMENTS = [
{ key: "minLength", label: "At least 8 characters", regex: /.{8,}/ },
{ key: "uppercase", label: "One uppercase letter", regex: /[A-Z]/ },
{ key: "lowercase", label: "One lowercase letter", regex: /[a-z]/ },
{ key: "number", label: "One number", regex: /[0-9]/ },
{ key: "special", label: "One special character", regex: /[^A-Za-z0-9]/ },
] as const;
export type PasswordRequirementKey = (typeof PASSWORD_REQUIREMENTS)[number]["key"];
/**
* Check password strength against requirements
*/
export function checkPasswordStrength(password: string): {
requirements: Array<{ key: string; label: string; met: boolean }>;
strength: number;
isValid: boolean;
} {
const requirements = PASSWORD_REQUIREMENTS.map(req => ({
key: req.key,
label: req.label,
met: req.regex.test(password),
}));
const metCount = requirements.filter(r => r.met).length;
const strength = (metCount / requirements.length) * 100;
const isValid = metCount === requirements.length;
return { requirements, strength, isValid };
}
/**
* Get password strength display label and color class
*/
export function getPasswordStrengthDisplay(strength: number): {
label: string;
colorClass: string;
} {
if (strength >= 100) return { label: "Strong", colorClass: "bg-green-500" };
if (strength >= 80) return { label: "Good", colorClass: "bg-blue-500" };
if (strength >= 60) return { label: "Fair", colorClass: "bg-yellow-500" };
return { label: "Weak", colorClass: "bg-red-500" };
}
// ============================================================================
// Migration Info (Business Constants)
// ============================================================================
export const MIGRATION_TRANSFER_ITEMS = [
"All active services",
"Billing history",
"Support tickets",
"Account details",
] as const;
export const MIGRATION_STEPS = [
"Enter your legacy portal email and password",
"We verify your account and migrate your data",
"Create a new secure password for the upgraded portal",
"Access your dashboard with all your services ready",
] as const;

View File

@ -57,7 +57,7 @@ export type {
export { export {
// Request schemas // Request schemas
loginRequestSchema, loginRequestSchema,
signupInputSchema, // Base input schema for forms signupInputSchema,
signupRequestSchema, signupRequestSchema,
passwordResetRequestSchema, passwordResetRequestSchema,
passwordResetSchema, passwordResetSchema,
@ -86,3 +86,16 @@ export {
} from "./schema"; } from "./schema";
export { buildSignupRequest } from "./helpers"; export { buildSignupRequest } from "./helpers";
// ============================================================================
// Password Utilities
// ============================================================================
export {
PASSWORD_REQUIREMENTS,
checkPasswordStrength,
getPasswordStrengthDisplay,
MIGRATION_TRANSFER_ITEMS,
MIGRATION_STEPS,
type PasswordRequirementKey,
} from "./forms";

View File

@ -50,6 +50,17 @@ export interface PricingTier {
originalPrice?: number; originalPrice?: number;
} }
/**
* Standardized pricing display info
* Used for consistent price rendering across Frontend and BFF
*/
export interface CatalogPriceInfo {
display: string;
monthly: number | null;
oneTime: number | null;
currency: string;
}
/** /**
* Catalog filtering options * Catalog filtering options
*/ */

View File

@ -11,6 +11,7 @@ export {
type SalesforceProductFieldMap, type SalesforceProductFieldMap,
type PricingTier, type PricingTier,
type CatalogFilter, type CatalogFilter,
type CatalogPriceInfo,
} from "./contract"; } from "./contract";
// Schemas (includes derived types) // Schemas (includes derived types)

View File

@ -8,7 +8,9 @@ import {
type SimCatalogCollection, type SimCatalogCollection,
type VpnCatalogCollection, type VpnCatalogCollection,
type InternetPlanTemplate, type InternetPlanTemplate,
type CatalogProductBase,
} from "./schema"; } from "./schema";
import type { CatalogPriceInfo } from "./contract";
/** /**
* Empty catalog defaults shared by portal and BFF. * Empty catalog defaults shared by portal and BFF.
@ -156,3 +158,39 @@ export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): Inter
export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array(); export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array();
/**
* Calculates display price information for a catalog item
* Centralized logic for price formatting
*/
export function getCatalogProductPriceDisplay(item: CatalogProductBase): CatalogPriceInfo | null {
const monthlyPrice = item.monthlyPrice ?? null;
const oneTimePrice = item.oneTimePrice ?? null;
const currency = "JPY";
if (monthlyPrice === null && oneTimePrice === null) {
return null;
}
let display = "";
if (monthlyPrice !== null && monthlyPrice > 0) {
display = `¥${monthlyPrice.toLocaleString()}/month`;
} else if (oneTimePrice !== null && oneTimePrice > 0) {
display = `¥${oneTimePrice.toLocaleString()} (one-time)`;
}
return {
display,
monthly: monthlyPrice,
oneTime: oneTimePrice,
currency,
};
}
/**
* Calculate savings percentage between original and current price
* Returns 0 if there are no savings (current >= original)
*/
export function calculateSavingsPercentage(originalPrice: number, currentPrice: number): number {
if (originalPrice <= currentPrice) return 0;
return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
}

View File

@ -0,0 +1,479 @@
/**
* Unified Error Handling for Customer Portal
*
* Single source of truth for error codes and user-friendly messages.
* Used by both BFF (for generating responses) and Portal (for parsing responses).
*/
import { z } from "zod";
// ============================================================================
// Error Categories and Severity
// ============================================================================
export type ErrorCategory =
| "authentication"
| "authorization"
| "validation"
| "business"
| "system"
| "network";
export type ErrorSeverity = "low" | "medium" | "high" | "critical";
// ============================================================================
// Error Code Registry
// ============================================================================
/**
* All error codes used in the application.
* Format: CATEGORY_NUMBER (e.g., AUTH_001, VAL_001)
*/
export const ErrorCode = {
// Authentication Errors (AUTH_*)
INVALID_CREDENTIALS: "AUTH_001",
ACCOUNT_LOCKED: "AUTH_002",
SESSION_EXPIRED: "AUTH_003",
TOKEN_INVALID: "AUTH_004",
TOKEN_REVOKED: "AUTH_005",
REFRESH_TOKEN_INVALID: "AUTH_006",
// Authorization Errors (AUTHZ_*)
FORBIDDEN: "AUTHZ_001",
ADMIN_REQUIRED: "AUTHZ_002",
RESOURCE_ACCESS_DENIED: "AUTHZ_003",
// Validation Errors (VAL_*)
VALIDATION_FAILED: "VAL_001",
REQUIRED_FIELD_MISSING: "VAL_002",
INVALID_FORMAT: "VAL_003",
NOT_FOUND: "VAL_004",
// Business Logic Errors (BIZ_*)
ACCOUNT_EXISTS: "BIZ_001",
ACCOUNT_ALREADY_LINKED: "BIZ_002",
CUSTOMER_NOT_FOUND: "BIZ_003",
ORDER_ALREADY_PROCESSED: "BIZ_004",
INSUFFICIENT_BALANCE: "BIZ_005",
SERVICE_UNAVAILABLE: "BIZ_006",
// System Errors (SYS_*)
INTERNAL_ERROR: "SYS_001",
EXTERNAL_SERVICE_ERROR: "SYS_002",
DATABASE_ERROR: "SYS_003",
CONFIGURATION_ERROR: "SYS_004",
// Network/Rate Limiting (NET_*)
NETWORK_ERROR: "NET_001",
TIMEOUT: "NET_002",
RATE_LIMITED: "NET_003",
// Generic
UNKNOWN: "GEN_001",
} as const;
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
// ============================================================================
// Error Messages (User-Friendly)
// ============================================================================
/**
* User-friendly error messages for each error code.
* These are safe to display to end users.
*/
export const ErrorMessages: Record<ErrorCodeType, string> = {
// Authentication
[ErrorCode.INVALID_CREDENTIALS]: "Invalid email or password. Please try again.",
[ErrorCode.ACCOUNT_LOCKED]:
"Your account has been temporarily locked due to multiple failed login attempts. Please try again later.",
[ErrorCode.SESSION_EXPIRED]: "Your session has expired. Please log in again.",
[ErrorCode.TOKEN_INVALID]: "Your session is invalid. Please log in again.",
[ErrorCode.TOKEN_REVOKED]: "Your session has been revoked. Please log in again.",
[ErrorCode.REFRESH_TOKEN_INVALID]: "Your session could not be refreshed. Please log in again.",
// Authorization
[ErrorCode.FORBIDDEN]: "You do not have permission to perform this action.",
[ErrorCode.ADMIN_REQUIRED]: "Administrator access is required for this action.",
[ErrorCode.RESOURCE_ACCESS_DENIED]: "You do not have access to this resource.",
// Validation
[ErrorCode.VALIDATION_FAILED]: "The provided data is invalid. Please check your input.",
[ErrorCode.REQUIRED_FIELD_MISSING]: "Required information is missing.",
[ErrorCode.INVALID_FORMAT]: "The data format is invalid.",
[ErrorCode.NOT_FOUND]: "The requested resource was not found.",
// Business Logic
[ErrorCode.ACCOUNT_EXISTS]: "An account with this email already exists. Please sign in.",
[ErrorCode.ACCOUNT_ALREADY_LINKED]: "This billing account is already linked. Please sign in.",
[ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.",
[ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.",
[ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.",
[ErrorCode.SERVICE_UNAVAILABLE]: "This service is temporarily unavailable. Please try again later.",
// System
[ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.",
[ErrorCode.EXTERNAL_SERVICE_ERROR]: "An external service is temporarily unavailable. Please try again later.",
[ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.",
[ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.",
// Network
[ErrorCode.NETWORK_ERROR]: "Unable to connect to the server. Please check your internet connection.",
[ErrorCode.TIMEOUT]: "The request timed out. Please try again.",
[ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.",
// Generic
[ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.",
};
// ============================================================================
// Error Metadata
// ============================================================================
interface ErrorMetadata {
category: ErrorCategory;
severity: ErrorSeverity;
shouldLogout: boolean;
shouldRetry: boolean;
logLevel: "error" | "warn" | "info" | "debug";
}
/**
* Metadata for each error code defining behavior and classification.
*/
export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
// Authentication - mostly medium severity, some trigger logout
[ErrorCode.INVALID_CREDENTIALS]: {
category: "authentication",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ACCOUNT_LOCKED]: {
category: "authentication",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.SESSION_EXPIRED]: {
category: "authentication",
severity: "low",
shouldLogout: true,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.TOKEN_INVALID]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.TOKEN_REVOKED]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.REFRESH_TOKEN_INVALID]: {
category: "authentication",
severity: "medium",
shouldLogout: true,
shouldRetry: false,
logLevel: "warn",
},
// Authorization
[ErrorCode.FORBIDDEN]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ADMIN_REQUIRED]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.RESOURCE_ACCESS_DENIED]: {
category: "authorization",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
// Validation - low severity, informational
[ErrorCode.VALIDATION_FAILED]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.REQUIRED_FIELD_MISSING]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.INVALID_FORMAT]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.NOT_FOUND]: {
category: "validation",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
// Business Logic
[ErrorCode.ACCOUNT_EXISTS]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.ACCOUNT_ALREADY_LINKED]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.CUSTOMER_NOT_FOUND]: {
category: "business",
severity: "medium",
shouldLogout: false,
shouldRetry: false,
logLevel: "warn",
},
[ErrorCode.ORDER_ALREADY_PROCESSED]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.INSUFFICIENT_BALANCE]: {
category: "business",
severity: "low",
shouldLogout: false,
shouldRetry: false,
logLevel: "info",
},
[ErrorCode.SERVICE_UNAVAILABLE]: {
category: "business",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
// System - high severity
[ErrorCode.INTERNAL_ERROR]: {
category: "system",
severity: "high",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.EXTERNAL_SERVICE_ERROR]: {
category: "system",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.DATABASE_ERROR]: {
category: "system",
severity: "critical",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
[ErrorCode.CONFIGURATION_ERROR]: {
category: "system",
severity: "critical",
shouldLogout: false,
shouldRetry: false,
logLevel: "error",
},
// Network
[ErrorCode.NETWORK_ERROR]: {
category: "network",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
[ErrorCode.TIMEOUT]: {
category: "network",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
[ErrorCode.RATE_LIMITED]: {
category: "network",
severity: "low",
shouldLogout: false,
shouldRetry: true,
logLevel: "warn",
},
// Generic
[ErrorCode.UNKNOWN]: {
category: "system",
severity: "medium",
shouldLogout: false,
shouldRetry: true,
logLevel: "error",
},
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Get user-friendly message for an error code
*/
export function getErrorMessage(code: string): string {
return ErrorMessages[code as ErrorCodeType] ?? ErrorMessages[ErrorCode.UNKNOWN];
}
/**
* Get metadata for an error code
*/
export function getErrorMetadata(code: string): ErrorMetadata {
return ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN];
}
/**
* Check if error code should trigger logout
*/
export function shouldLogoutForCode(code: string): boolean {
return getErrorMetadata(code).shouldLogout;
}
/**
* Check if error can be retried
*/
export function canRetryError(code: string): boolean {
return getErrorMetadata(code).shouldRetry;
}
// ============================================================================
// Pattern Matching for Error Classification
// ============================================================================
interface ErrorPattern {
pattern: RegExp;
code: ErrorCodeType;
}
/**
* Patterns to match error messages to error codes.
* Used when explicit error codes are not available.
*/
export const ErrorPatterns: ErrorPattern[] = [
// Authentication patterns
{ pattern: /invalid.*credentials?|wrong.*password|invalid.*password/i, code: ErrorCode.INVALID_CREDENTIALS },
{ pattern: /account.*locked|locked.*account|too.*many.*attempts/i, code: ErrorCode.ACCOUNT_LOCKED },
{ pattern: /session.*expired|expired.*session/i, code: ErrorCode.SESSION_EXPIRED },
{ pattern: /token.*expired|expired.*token/i, code: ErrorCode.SESSION_EXPIRED },
{ pattern: /token.*revoked|revoked.*token/i, code: ErrorCode.TOKEN_REVOKED },
{ pattern: /invalid.*token|token.*invalid/i, code: ErrorCode.TOKEN_INVALID },
{ pattern: /refresh.*token.*invalid|invalid.*refresh/i, code: ErrorCode.REFRESH_TOKEN_INVALID },
// Authorization patterns
{ pattern: /admin.*required|requires?.*admin/i, code: ErrorCode.ADMIN_REQUIRED },
{ pattern: /forbidden|not.*authorized|unauthorized/i, code: ErrorCode.FORBIDDEN },
{ pattern: /access.*denied|permission.*denied/i, code: ErrorCode.RESOURCE_ACCESS_DENIED },
// Business patterns
{ pattern: /already.*exists|email.*exists|account.*exists/i, code: ErrorCode.ACCOUNT_EXISTS },
{ pattern: /already.*linked/i, code: ErrorCode.ACCOUNT_ALREADY_LINKED },
{ pattern: /customer.*not.*found|account.*not.*found/i, code: ErrorCode.CUSTOMER_NOT_FOUND },
{ pattern: /already.*processed/i, code: ErrorCode.ORDER_ALREADY_PROCESSED },
{ pattern: /insufficient.*balance/i, code: ErrorCode.INSUFFICIENT_BALANCE },
// System patterns
{ pattern: /database|sql|postgres|prisma|connection.*refused/i, code: ErrorCode.DATABASE_ERROR },
{ pattern: /whmcs|salesforce|external.*service/i, code: ErrorCode.EXTERNAL_SERVICE_ERROR },
{ pattern: /configuration.*error|missing.*config/i, code: ErrorCode.CONFIGURATION_ERROR },
// Network patterns
{ pattern: /network.*error|fetch.*failed|econnrefused/i, code: ErrorCode.NETWORK_ERROR },
{ pattern: /timeout|timed?\s*out/i, code: ErrorCode.TIMEOUT },
{ pattern: /too.*many.*requests|rate.*limit/i, code: ErrorCode.RATE_LIMITED },
// Validation patterns (lower priority - checked last)
{ pattern: /not.*found/i, code: ErrorCode.NOT_FOUND },
{ pattern: /validation.*failed|invalid/i, code: ErrorCode.VALIDATION_FAILED },
{ pattern: /required|missing/i, code: ErrorCode.REQUIRED_FIELD_MISSING },
];
/**
* Match an error message to an error code using patterns
*/
export function matchErrorPattern(message: string): ErrorCodeType {
for (const { pattern, code } of ErrorPatterns) {
if (pattern.test(message)) {
return code;
}
}
return ErrorCode.UNKNOWN;
}
// ============================================================================
// Zod Schema for Error Response
// ============================================================================
/**
* Schema for standard API error response
*/
export const apiErrorSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.record(z.string(), z.unknown()).optional(),
}),
});
export type ApiError = z.infer<typeof apiErrorSchema>;
/**
* Create a standard error response object
*/
export function createErrorResponse(
code: ErrorCodeType,
customMessage?: string,
details?: Record<string, unknown>
): ApiError {
return {
success: false,
error: {
code,
message: customMessage ?? getErrorMessage(code),
details,
},
};
}

View File

@ -1,12 +1,13 @@
/** /**
* Common Domain * Common Domain
* *
* Shared types and utilities used across all domains. * Shared types and utilities used across all domains.
*/ */
export * from "./types"; export * from "./types";
export * from "./schema"; export * from "./schema";
export * from "./validation"; export * from "./validation";
export * from "./errors";
// Common provider types (generic wrappers used across domains) // Common provider types (generic wrappers used across domains)
export * as CommonProviders from "./providers/index"; export * as CommonProviders from "./providers/index";

View File

@ -4,3 +4,4 @@
export * from "./contract"; export * from "./contract";
export * from "./schema"; export * from "./schema";
export * from "./utils";

View File

@ -0,0 +1,104 @@
/**
* Dashboard Domain Utilities
* Business logic for dashboard activities and task generation
*/
import type { Activity, ActivityFilter, ActivityFilterConfig, ActivityType } from "./contract";
/**
* Activity filter configurations
* Defines which activity types belong to each filter category
*/
export const ACTIVITY_FILTERS: ActivityFilterConfig[] = [
{ key: "all", label: "All" },
{
key: "billing",
label: "Billing",
types: ["invoice_created", "invoice_paid"],
},
{
key: "orders",
label: "Orders",
types: ["service_activated"],
},
{
key: "support",
label: "Support",
types: ["case_created", "case_closed"],
},
];
/**
* Filter activities by type category
*/
export function filterActivities(activities: Activity[], filter: ActivityFilter): Activity[] {
if (filter === "all") {
return activities;
}
const filterConfig = ACTIVITY_FILTERS.find(f => f.key === filter);
if (!filterConfig?.types) {
return activities;
}
return activities.filter(activity => filterConfig.types!.includes(activity.type));
}
/**
* Activity types that support navigation to detail views
*/
const CLICKABLE_ACTIVITY_TYPES: ActivityType[] = ["invoice_created", "invoice_paid"];
/**
* Check if an activity is clickable (has a navigable detail view)
*/
export function isActivityClickable(activity: Activity): boolean {
return CLICKABLE_ACTIVITY_TYPES.includes(activity.type) && !!activity.relatedId;
}
/**
* Dashboard task definition
*/
export interface DashboardTask {
label: string;
href: string;
}
/**
* Dashboard summary input for task generation
*/
export interface DashboardTaskSummary {
nextInvoice?: { id: number } | null;
stats?: { unpaidInvoices?: number; openCases?: number };
}
/**
* Generate dashboard task suggestions based on summary data
*/
export function generateDashboardTasks(summary: DashboardTaskSummary): DashboardTask[] {
const tasks: DashboardTask[] = [];
if (summary.nextInvoice) {
tasks.push({
label: "Pay upcoming invoice",
href: "#attention",
});
}
if (summary.stats?.unpaidInvoices && summary.stats.unpaidInvoices > 0) {
tasks.push({
label: "Review unpaid invoices",
href: "/billing/invoices",
});
}
if (summary.stats?.openCases && summary.stats.openCases > 0) {
tasks.push({
label: "Check support cases",
href: "/support/cases",
});
}
return tasks;
}

View File

@ -5,7 +5,7 @@ import {
type CreateOrderRequest, type CreateOrderRequest,
type OrderSelections, type OrderSelections,
} from "./schema"; } from "./schema";
import { ORDER_TYPE } from "./contract"; import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract";
export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations { export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations {
const normalizedSelections = orderSelectionsSchema.parse(selections); const normalizedSelections = orderSelectionsSchema.parse(selections);
@ -41,3 +41,38 @@ export function createOrderRequest(payload: {
}; };
} }
/**
* Transform CheckoutCart into CreateOrderRequest
* Handles SKU extraction, validation, and payload formatting
*
* @throws Error if no products are selected
*/
export function prepareOrderFromCart(
cart: CheckoutCart,
orderType: OrderTypeValue
): CreateOrderRequest {
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
if (uniqueSkus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
// Note: Zod validation of the final structure should happen at the boundary or via schema.parse
// This function focuses on the structural transformation logic.
const orderData: CreateOrderRequest = {
orderType,
skus: uniqueSkus,
...(Object.keys(cart.configuration).length > 0
? { configurations: cart.configuration }
: {}),
};
return orderData;
}

View File

@ -2,26 +2,73 @@
* Support Domain - Contract * Support Domain - Contract
* *
* Constants for support case statuses, priorities, and categories. * Constants for support case statuses, priorities, and categories.
* These are the DISPLAY values shown in the portal UI.
*
* Note: Salesforce uses Japanese API names internally (e.g., , , , , )
* which are mapped to these English display values by the Salesforce mapper.
*/ */
/**
* Portal display status values
* Mapped from Salesforce Japanese API names:
* - New
* - In Progress
* - Awaiting Approval Awaiting Approval
* - VPN Pending VPN Pending
* - Pending Pending
* - Resolved
* - Closed Closed
*/
export const SUPPORT_CASE_STATUS = { export const SUPPORT_CASE_STATUS = {
OPEN: "Open", NEW: "New",
IN_PROGRESS: "In Progress", IN_PROGRESS: "In Progress",
WAITING_ON_CUSTOMER: "Waiting on Customer", AWAITING_APPROVAL: "Awaiting Approval",
VPN_PENDING: "VPN Pending",
PENDING: "Pending",
RESOLVED: "Resolved", RESOLVED: "Resolved",
CLOSED: "Closed", CLOSED: "Closed",
} as const; } as const;
/** Statuses that indicate a case is closed */
export const CLOSED_STATUSES = [
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED,
] as const;
/** Statuses that indicate a case is open */
export const OPEN_STATUSES = [
SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
] as const;
/**
* Portal display priority values
* Mapped from Salesforce Japanese API names:
* - High
* - Medium
* - Low
*/
export const SUPPORT_CASE_PRIORITY = { export const SUPPORT_CASE_PRIORITY = {
LOW: "Low", LOW: "Low",
MEDIUM: "Medium", MEDIUM: "Medium",
HIGH: "High", HIGH: "High",
CRITICAL: "Critical",
} as const; } as const;
/**
* Case categories map to Salesforce Case.Type field
* Note: Type picklist may not be configured in your org
*/
export const SUPPORT_CASE_CATEGORY = { export const SUPPORT_CASE_CATEGORY = {
TECHNICAL: "Technical", TECHNICAL: "Technical",
BILLING: "Billing", BILLING: "Billing",
GENERAL: "General", GENERAL: "General",
FEATURE_REQUEST: "Feature Request", FEATURE_REQUEST: "Feature Request",
} as const; } as const;
/**
* Portal Website origin - used to filter and create portal cases
*/
export const PORTAL_CASE_ORIGIN = "Portal Website" as const;

View File

@ -1,7 +1,25 @@
/**
* Support Domain
*
* Exports all support-related contracts, schemas, and provider mappers.
*
* Types are derived from Zod schemas (Schema-First Approach)
*/
// Business types and constants
export { export {
SUPPORT_CASE_STATUS, SUPPORT_CASE_STATUS,
SUPPORT_CASE_PRIORITY, SUPPORT_CASE_PRIORITY,
SUPPORT_CASE_CATEGORY, SUPPORT_CASE_CATEGORY,
PORTAL_CASE_ORIGIN,
} from "./contract"; } from "./contract";
// Schemas (includes derived types)
export * from "./schema"; export * from "./schema";
// Provider adapters
export * as Providers from "./providers/index";
// Re-export provider types for convenience
export * from "./providers/salesforce/raw.types";
export * from "./providers/salesforce/mapper";

View File

@ -0,0 +1,18 @@
/**
* Support Domain - Providers
*/
import * as SalesforceMapper from "./salesforce/mapper";
import * as SalesforceRaw from "./salesforce/raw.types";
export const Salesforce = {
...SalesforceMapper,
...SalesforceRaw,
mapper: SalesforceMapper,
raw: SalesforceRaw,
};
export { SalesforceMapper, SalesforceRaw };
export * from "./salesforce/mapper";
export * from "./salesforce/raw.types";

View File

@ -0,0 +1,7 @@
/**
* Support Domain - Salesforce Provider
*/
export * from "./raw.types";
export * from "./mapper";

View File

@ -0,0 +1,170 @@
/**
* Support Domain - Salesforce Provider Mapper
*
* Transform functions to convert raw Salesforce Case records to domain types.
*/
import { supportCaseSchema, type SupportCase } from "../../schema";
import type { SalesforceCaseRecord } from "./raw.types";
import {
getStatusDisplayLabel,
getPriorityDisplayLabel,
SALESFORCE_CASE_STATUS,
SALESFORCE_CASE_PRIORITY,
} from "./raw.types";
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Safely coerce a value to string or return undefined
*/
function ensureString(value: unknown): string | undefined {
if (typeof value === "string" && value.length > 0) {
return value;
}
return undefined;
}
/**
* Get current ISO timestamp
*/
function nowIsoString(): string {
return new Date().toISOString();
}
// ============================================================================
// Transform Functions
// ============================================================================
/**
* Transform a raw Salesforce Case record to a portal SupportCase.
*
* Converts Salesforce API values (often in Japanese) to portal display labels (English).
*
* @param record - Raw Salesforce Case record from SOQL query
* @returns Validated SupportCase domain object
*/
export function transformSalesforceCaseToSupportCase(
record: SalesforceCaseRecord
): SupportCase {
// Get raw values
const rawStatus = ensureString(record.Status) ?? SALESFORCE_CASE_STATUS.NEW;
const rawPriority = ensureString(record.Priority) ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
return supportCaseSchema.parse({
id: record.Id,
caseNumber: record.CaseNumber,
subject: ensureString(record.Subject) ?? "",
// Convert Japanese SF values to English display labels
status: getStatusDisplayLabel(rawStatus),
priority: getPriorityDisplayLabel(rawPriority),
category: ensureString(record.Type) ?? null,
description: ensureString(record.Description) ?? "",
createdAt: ensureString(record.CreatedDate) ?? nowIsoString(),
updatedAt: ensureString(record.LastModifiedDate) ?? nowIsoString(),
closedAt: ensureString(record.ClosedDate) ?? null,
});
}
/**
* Transform multiple Salesforce Case records to SupportCase array.
*
* @param records - Array of raw Salesforce Case records
* @returns Array of validated SupportCase domain objects
*/
export function transformSalesforceCasesToSupportCases(
records: SalesforceCaseRecord[]
): SupportCase[] {
return records.map(transformSalesforceCaseToSupportCase);
}
/**
* Build the SOQL SELECT fields for Case queries.
*
* Standard Salesforce Case fields based on org configuration.
* Note: Type field is not accessible via API in this org.
*
* @param additionalFields - Optional additional fields to include
* @returns Array of field names for SOQL SELECT clause
*/
export function buildCaseSelectFields(additionalFields: string[] = []): string[] {
const baseFields = [
// Core identifiers
"Id",
"CaseNumber",
// Case content
"Subject",
"Description",
// Picklist fields
"Status",
"Priority",
"Origin",
// Relationships
"AccountId",
"ContactId",
"OwnerId",
// Timestamps
"CreatedDate",
"LastModifiedDate",
"ClosedDate",
// Flags
"IsEscalated",
];
return [...new Set([...baseFields, ...additionalFields])];
}
/**
* Build a SOQL query for fetching cases for an account.
*
* @param accountId - Salesforce Account ID
* @param origin - Case origin to filter by (e.g., "Portal Website")
* @param additionalFields - Optional additional fields to include
* @returns SOQL query string
*/
export function buildCasesForAccountQuery(
accountId: string,
origin: string,
additionalFields: string[] = []
): string {
const fields = buildCaseSelectFields(additionalFields).join(", ");
return `
SELECT ${fields}
FROM Case
WHERE AccountId = '${accountId}' AND Origin = '${origin}'
ORDER BY CreatedDate DESC
LIMIT 100
`.trim();
}
/**
* Build a SOQL query for fetching a single case by ID.
*
* @param caseId - Salesforce Case ID
* @param accountId - Salesforce Account ID (for ownership validation)
* @param origin - Case origin to filter by
* @param additionalFields - Optional additional fields to include
* @returns SOQL query string
*/
export function buildCaseByIdQuery(
caseId: string,
accountId: string,
origin: string,
additionalFields: string[] = []
): string {
const fields = buildCaseSelectFields(additionalFields).join(", ");
return `
SELECT ${fields}
FROM Case
WHERE Id = '${caseId}' AND AccountId = '${accountId}' AND Origin = '${origin}'
LIMIT 1
`.trim();
}

View File

@ -0,0 +1,322 @@
/**
* Support Domain - Salesforce Provider Raw Types
*
* Raw Salesforce API response types for Case sobject.
* These types represent the exact structure returned by Salesforce SOQL queries.
*
* Available Case fields in this org (standard fields used):
* - Id, CaseNumber, Subject, Status, Priority, Type, Description, Origin, Reason
* - AccountId, ContactId, OwnerId, ParentId
* - CreatedDate, ClosedDate (LastModifiedDate via LastModifiedById)
* - IsEscalated, IsClosedOnCreate, IsStopped
* - ContactEmail, ContactPhone, ContactMobile, ContactFax
*/
import { z } from "zod";
// ============================================================================
// Salesforce Case Record (Raw API Response)
// ============================================================================
/**
* Raw Salesforce Case record schema
*
* Represents the structure returned by SOQL queries on the Case object.
* All fields are optional/nullable to handle partial queries and Salesforce nulls.
*/
export const salesforceCaseRecordSchema = z.object({
// ─────────────────────────────────────────────────────────────────────────
// Standard Salesforce ID fields
// ─────────────────────────────────────────────────────────────────────────
Id: z.string(),
CaseNumber: z.string(),
// ─────────────────────────────────────────────────────────────────────────
// Core case fields (Picklists & Text)
// ─────────────────────────────────────────────────────────────────────────
Subject: z.string().nullable().optional(), // Text(255)
Status: z.string().nullable().optional(), // Picklist
Priority: z.string().nullable().optional(), // Picklist
Type: z.string().nullable().optional(), // Picklist - Maps to Category in portal
Description: z.string().nullable().optional(), // Long Text Area(32000)
Origin: z.string().nullable().optional(), // Picklist (Case Origin)
Reason: z.string().nullable().optional(), // Picklist (Case Reason)
// ─────────────────────────────────────────────────────────────────────────
// Relationship IDs (Lookups)
// ─────────────────────────────────────────────────────────────────────────
AccountId: z.string().nullable().optional(), // Lookup(Account)
ContactId: z.string().nullable().optional(), // Lookup(Contact)
OwnerId: z.string().nullable().optional(), // Lookup(User,Group)
ParentId: z.string().nullable().optional(), // Lookup(Case)
AssetId: z.string().nullable().optional(), // Lookup(Asset)
ProductId: z.string().nullable().optional(), // Lookup(Product)
EntitlementId: z.string().nullable().optional(), // Lookup(Entitlement)
ServiceContractId: z.string().nullable().optional(), // Lookup(Service Contract)
SourceId: z.string().nullable().optional(), // Lookup(Chat Transcript,Email Message,etc)
// ─────────────────────────────────────────────────────────────────────────
// Nested objects (from relationship queries)
// ─────────────────────────────────────────────────────────────────────────
Account: z
.object({
Id: z.string().optional(),
Name: z.string().nullable().optional(),
})
.nullable()
.optional(),
Contact: z
.object({
Id: z.string().optional(),
Name: z.string().nullable().optional(),
Email: z.string().nullable().optional(),
})
.nullable()
.optional(),
Owner: z
.object({
Id: z.string().optional(),
Name: z.string().nullable().optional(),
})
.nullable()
.optional(),
// ─────────────────────────────────────────────────────────────────────────
// Contact info fields (derived from Contact lookup)
// ─────────────────────────────────────────────────────────────────────────
ContactEmail: z.string().nullable().optional(), // Email
ContactPhone: z.string().nullable().optional(), // Phone
ContactMobile: z.string().nullable().optional(), // Phone
ContactFax: z.string().nullable().optional(), // Phone
// ─────────────────────────────────────────────────────────────────────────
// Timestamps
// ─────────────────────────────────────────────────────────────────────────
CreatedDate: z.string().nullable().optional(), // Date/Time Opened
LastModifiedDate: z.string().nullable().optional(), // Derived from LastModifiedById
ClosedDate: z.string().nullable().optional(), // Date/Time Closed
SlaStartDate: z.string().nullable().optional(), // SLA Policy Start Time
SlaExitDate: z.string().nullable().optional(), // SLA Policy End Time
// ─────────────────────────────────────────────────────────────────────────
// Boolean flags
// ─────────────────────────────────────────────────────────────────────────
IsEscalated: z.boolean().nullable().optional(), // Escalated checkbox
IsClosedOnCreate: z.boolean().nullable().optional(), // Closed When Created
IsStopped: z.boolean().nullable().optional(), // Stopped checkbox
// ─────────────────────────────────────────────────────────────────────────
// Other standard fields
// ─────────────────────────────────────────────────────────────────────────
Comments: z.string().nullable().optional(), // Internal Comments - Text Area(4000)
MilestoneStatus: z.string().nullable().optional(), // Text(30)
Language: z.string().nullable().optional(), // Picklist
// Web-to-Case fields
SuppliedName: z.string().nullable().optional(), // Web Name - Text(80)
SuppliedEmail: z.string().nullable().optional(), // Web Email - Email
SuppliedPhone: z.string().nullable().optional(), // Web Phone - Text(40)
SuppliedCompany: z.string().nullable().optional(), // Web Company - Text(80)
// ─────────────────────────────────────────────────────────────────────────
// Custom fields (org-specific)
// ─────────────────────────────────────────────────────────────────────────
// AI-related fields
Case_Concern_AI__c: z.string().nullable().optional(), // Text Area(255)
Case_Email_Summary__c: z.string().nullable().optional(), // Long Text Area(32768)
Summary_AI__c: z.string().nullable().optional(), // Long Text Area(32768)
Resolution_AI__c: z.string().nullable().optional(), // Long Text Area(32768)
// Other custom fields (add as needed)
Department__c: z.string().nullable().optional(), // Picklist
Comment__c: z.string().nullable().optional(), // Long Text Area(32768)
Notes__c: z.string().nullable().optional(), // Long Text Area(32768)
});
export type SalesforceCaseRecord = z.infer<typeof salesforceCaseRecordSchema>;
// ============================================================================
// Salesforce Case Create Payload
// ============================================================================
/**
* Payload schema for creating a new Case in Salesforce
*
* Uses Salesforce API names (Japanese) for Status and Priority defaults.
*/
export const salesforceCaseCreatePayloadSchema = z.object({
Subject: z.string(),
Description: z.string(),
Status: z.string().default("新規"), // Default: New (Japanese API name)
Priority: z.string().default("中"), // Default: Medium (Japanese API name)
Origin: z.string(),
Type: z.string().optional(),
AccountId: z.string(),
ContactId: z.string().optional(),
});
export type SalesforceCaseCreatePayload = z.infer<typeof salesforceCaseCreatePayloadSchema>;
// ============================================================================
// Salesforce Case Field Constants (Org-Specific Picklist Values)
// ============================================================================
/**
* Salesforce Case Origin picklist values
* API Names from Salesforce Setup > Object Manager > Case > Fields > Case Origin
*/
export const SALESFORCE_CASE_ORIGIN = {
// Portal origin (used for portal-created cases)
PORTAL_WEBSITE: "Portal Website",
// Phone/Email origins
PHONE: "電話", // Japanese: Phone
MAIL: "メール", // Japanese: Mail
EMAIL: "Email",
WEB: "Web",
// ChatBot origins
AS_CHATBOT: "AS ChatBot",
GENKI_CHATBOT: "Genki ChatBot",
// Messaging origins
WHATSAPP_CHAT: "WhatsApp Chat",
MESSENGER_CHAT: "Messenger Chat",
// Mail-specific origins
GENKI_MAIL: "Genki Mail",
BILLING_MAIL: "Billing Mail",
TECH_MAIL: "Tech Mail",
HELPDESK_MAIL: "Helpdesk Mail",
MESERATI_MAIL: "Meserati Mail",
// Other
WEB_URGENT_INQUIRY: "Web Urgent Inquiry",
} as const;
export type SalesforceCaseOrigin =
(typeof SALESFORCE_CASE_ORIGIN)[keyof typeof SALESFORCE_CASE_ORIGIN];
/**
* Salesforce Case Status picklist values
* API Names from Salesforce Setup > Object Manager > Case > Fields > Status
*
* Closed statuses: VPN Pending, Pending, (Replied)
* Open statuses: 新規 (New), (Reply in Progress), Awaiting Approval
*/
export const SALESFORCE_CASE_STATUS = {
// Open statuses
NEW: "新規", // Japanese: New Email (Default)
IN_PROGRESS: "対応中", // Japanese: Reply in Progress
AWAITING_APPROVAL: "Awaiting Approval",
// Closed statuses
VPN_PENDING: "VPN Pending", // Closed
PENDING: "Pending", // Closed
REPLIED: "完了済み", // Japanese: Replied/Completed (Closed)
CLOSED: "Closed",
} as const;
export type SalesforceCaseStatus =
(typeof SALESFORCE_CASE_STATUS)[keyof typeof SALESFORCE_CASE_STATUS];
/** Status values that indicate a case is closed */
export const SALESFORCE_CLOSED_STATUSES: SalesforceCaseStatus[] = [
SALESFORCE_CASE_STATUS.VPN_PENDING,
SALESFORCE_CASE_STATUS.PENDING,
SALESFORCE_CASE_STATUS.REPLIED,
SALESFORCE_CASE_STATUS.CLOSED,
];
/** Status values that indicate a case is open */
export const SALESFORCE_OPEN_STATUSES: SalesforceCaseStatus[] = [
SALESFORCE_CASE_STATUS.NEW,
SALESFORCE_CASE_STATUS.IN_PROGRESS,
SALESFORCE_CASE_STATUS.AWAITING_APPROVAL,
];
/**
* Salesforce Case Priority picklist values
* API Names from Salesforce Setup > Object Manager > Case > Fields > Priority
*/
export const SALESFORCE_CASE_PRIORITY = {
HIGH: "高", // Japanese: High
MEDIUM: "中", // Japanese: Medium (Default)
LOW: "低", // Japanese: Low
MEDIUM_EN: "Medium", // English Medium (also exists)
} as const;
export type SalesforceCasePriority =
(typeof SALESFORCE_CASE_PRIORITY)[keyof typeof SALESFORCE_CASE_PRIORITY];
// ============================================================================
// Portal Display Labels (for UI rendering)
// ============================================================================
/**
* Map Salesforce status API names to portal display labels
*/
export const STATUS_DISPLAY_LABELS: Record<string, string> = {
[SALESFORCE_CASE_STATUS.NEW]: "New",
[SALESFORCE_CASE_STATUS.IN_PROGRESS]: "In Progress",
[SALESFORCE_CASE_STATUS.AWAITING_APPROVAL]: "Awaiting Approval",
[SALESFORCE_CASE_STATUS.VPN_PENDING]: "VPN Pending",
[SALESFORCE_CASE_STATUS.PENDING]: "Pending",
[SALESFORCE_CASE_STATUS.REPLIED]: "Resolved",
[SALESFORCE_CASE_STATUS.CLOSED]: "Closed",
};
/**
* Map Salesforce priority API names to portal display labels
*/
export const PRIORITY_DISPLAY_LABELS: Record<string, string> = {
[SALESFORCE_CASE_PRIORITY.HIGH]: "High",
[SALESFORCE_CASE_PRIORITY.MEDIUM]: "Medium",
[SALESFORCE_CASE_PRIORITY.LOW]: "Low",
[SALESFORCE_CASE_PRIORITY.MEDIUM_EN]: "Medium",
};
/**
* Get display label for a status value
*/
export function getStatusDisplayLabel(status: string): string {
return STATUS_DISPLAY_LABELS[status] ?? status;
}
/**
* Get display label for a priority value
*/
export function getPriorityDisplayLabel(priority: string): string {
return PRIORITY_DISPLAY_LABELS[priority] ?? priority;
}
/**
* Check if a status indicates the case is closed
*/
export function isClosedStatus(status: string): boolean {
return SALESFORCE_CLOSED_STATUSES.includes(status as SalesforceCaseStatus);
}
// ============================================================================
// Reverse Mapping (Portal Display → Salesforce API)
// ============================================================================
/**
* Map portal display priority to Salesforce API priority value
* Used when creating cases - converts "High" "高", etc.
*/
const PRIORITY_TO_SALESFORCE: Record<string, string> = {
High: SALESFORCE_CASE_PRIORITY.HIGH, // "高"
Medium: SALESFORCE_CASE_PRIORITY.MEDIUM, // "中"
Low: SALESFORCE_CASE_PRIORITY.LOW, // "低"
};
/**
* Convert portal priority display value to Salesforce API value
*/
export function toSalesforcePriority(displayPriority: string): string {
return PRIORITY_TO_SALESFORCE[displayPriority] ?? SALESFORCE_CASE_PRIORITY.MEDIUM;
}

View File

@ -5,19 +5,26 @@ import {
SUPPORT_CASE_CATEGORY, SUPPORT_CASE_CATEGORY,
} from "./contract"; } from "./contract";
/**
* Portal status values (mapped from Salesforce Japanese API names)
*/
const supportCaseStatusValues = [ const supportCaseStatusValues = [
SUPPORT_CASE_STATUS.OPEN, SUPPORT_CASE_STATUS.NEW,
SUPPORT_CASE_STATUS.IN_PROGRESS, SUPPORT_CASE_STATUS.IN_PROGRESS,
SUPPORT_CASE_STATUS.WAITING_ON_CUSTOMER, SUPPORT_CASE_STATUS.AWAITING_APPROVAL,
SUPPORT_CASE_STATUS.VPN_PENDING,
SUPPORT_CASE_STATUS.PENDING,
SUPPORT_CASE_STATUS.RESOLVED, SUPPORT_CASE_STATUS.RESOLVED,
SUPPORT_CASE_STATUS.CLOSED, SUPPORT_CASE_STATUS.CLOSED,
] as const; ] as const;
/**
* Portal priority values (mapped from Salesforce Japanese API names)
*/
const supportCasePriorityValues = [ const supportCasePriorityValues = [
SUPPORT_CASE_PRIORITY.LOW, SUPPORT_CASE_PRIORITY.LOW,
SUPPORT_CASE_PRIORITY.MEDIUM, SUPPORT_CASE_PRIORITY.MEDIUM,
SUPPORT_CASE_PRIORITY.HIGH, SUPPORT_CASE_PRIORITY.HIGH,
SUPPORT_CASE_PRIORITY.CRITICAL,
] as const; ] as const;
const supportCaseCategoryValues = [ const supportCaseCategoryValues = [
@ -31,17 +38,21 @@ export const supportCaseStatusSchema = z.enum(supportCaseStatusValues);
export const supportCasePrioritySchema = z.enum(supportCasePriorityValues); export const supportCasePrioritySchema = z.enum(supportCasePriorityValues);
export const supportCaseCategorySchema = z.enum(supportCaseCategoryValues); export const supportCaseCategorySchema = z.enum(supportCaseCategoryValues);
/**
* Support case schema - compatible with Salesforce Case object
* ID is a string (Salesforce ID format: 15 or 18 char alphanumeric)
*/
export const supportCaseSchema = z.object({ export const supportCaseSchema = z.object({
id: z.number().int().positive(), id: z.string().min(15).max(18),
caseNumber: z.string(),
subject: z.string().min(1), subject: z.string().min(1),
status: supportCaseStatusSchema, status: z.string(), // Allow any status from Salesforce
priority: supportCasePrioritySchema, priority: z.string(), // Allow any priority from Salesforce
category: supportCaseCategorySchema, category: z.string().nullable(), // Maps to Salesforce Type field
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
lastReply: z.string().optional(), closedAt: z.string().nullable(),
description: z.string(), description: z.string(),
assignedTo: z.string().optional(),
}); });
export const supportCaseSummarySchema = z.object({ export const supportCaseSummarySchema = z.object({
@ -58,13 +69,31 @@ export const supportCaseListSchema = z.object({
export const supportCaseFilterSchema = z export const supportCaseFilterSchema = z
.object({ .object({
status: supportCaseStatusSchema.optional(), status: z.string().optional(),
priority: supportCasePrioritySchema.optional(), priority: z.string().optional(),
category: supportCaseCategorySchema.optional(), category: z.string().optional(),
search: z.string().trim().min(1).optional(), search: z.string().trim().min(1).optional(),
}) })
.default({}); .default({});
/**
* Request schema for creating a new support case
*/
export const createCaseRequestSchema = z.object({
subject: z.string().min(1).max(255),
description: z.string().min(1).max(32000),
category: supportCaseCategorySchema.optional(),
priority: supportCasePrioritySchema.optional(),
});
/**
* Response schema for case creation
*/
export const createCaseResponseSchema = z.object({
id: z.string(),
caseNumber: z.string(),
});
export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>; export type SupportCaseStatus = z.infer<typeof supportCaseStatusSchema>;
export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>; export type SupportCasePriority = z.infer<typeof supportCasePrioritySchema>;
export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>; export type SupportCaseCategory = z.infer<typeof supportCaseCategorySchema>;
@ -72,3 +101,5 @@ export type SupportCase = z.infer<typeof supportCaseSchema>;
export type SupportCaseSummary = z.infer<typeof supportCaseSummarySchema>; export type SupportCaseSummary = z.infer<typeof supportCaseSummarySchema>;
export type SupportCaseList = z.infer<typeof supportCaseListSchema>; export type SupportCaseList = z.infer<typeof supportCaseListSchema>;
export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>; export type SupportCaseFilter = z.infer<typeof supportCaseFilterSchema>;
export type CreateCaseRequest = z.infer<typeof createCaseRequestSchema>;
export type CreateCaseResponse = z.infer<typeof createCaseResponseSchema>;