Merge branch 'main' into SIM_Tema
This commit is contained in:
commit
1d4891ad85
@ -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/*
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
304
apps/bff/src/core/http/exception.filter.ts
Normal file
304
apps/bff/src/core/http/exception.filter.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
}
|
||||||
|
|
||||||
12
apps/portal/src/app/(authenticated)/support/page.tsx
Normal file
12
apps/portal/src/app/(authenticated)/support/page.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { SupportHomeView } from "@/features/support";
|
||||||
|
import { AgentforceWidget } from "@/components";
|
||||||
|
|
||||||
|
export default function SupportPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SupportHomeView />
|
||||||
|
<AgentforceWidget />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { AgentforceWidget } from "./AgentforceWidget";
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { AppShell } from "./AppShell/AppShell";
|
export { AppShell } from "./AppShell/AppShell";
|
||||||
|
export { AgentforceWidget } from "./AgentforceWidget";
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 "Create Account", you'll be able to access your dashboard and
|
|
||||||
start using our services immediately.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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";
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 "Create Account", your account will be created and you can start managing your services.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export { AccountStep } from "./AccountStep";
|
||||||
|
export { AddressStep } from "./AddressStep";
|
||||||
|
export { PasswordStep } from "./PasswordStep";
|
||||||
|
export { ReviewStep } from "./ReviewStep";
|
||||||
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'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'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'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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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'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 */}
|
||||||
|
|||||||
@ -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[];
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
28
apps/portal/src/features/support/hooks/useCreateCase.ts
Normal file
28
apps/portal/src/features/support/hooks/useCreateCase.ts
Normal 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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
21
apps/portal/src/features/support/hooks/useSupportCase.ts
Normal file
21
apps/portal/src/features/support/hooks/useSupportCase.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
152
apps/portal/src/features/support/utils/case-presenters.tsx
Normal file
152
apps/portal/src/features/support/utils/case-presenters.tsx
Normal 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];
|
||||||
|
}
|
||||||
|
|
||||||
2
apps/portal/src/features/support/utils/index.ts
Normal file
2
apps/portal/src/features/support/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./case-presenters";
|
||||||
|
|
||||||
@ -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'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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
apps/portal/src/features/support/views/SupportCaseDetailView.tsx
Normal file
161
apps/portal/src/features/support/views/SupportCaseDetailView.tsx
Normal 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're looking for could not be found or you don'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
apps/portal/src/features/support/views/SupportHomeView.tsx
Normal file
161
apps/portal/src/features/support/views/SupportHomeView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,2 +1,4 @@
|
|||||||
export * from "./NewSupportCaseView";
|
export * from "./NewSupportCaseView";
|
||||||
export * from "./SupportCasesView";
|
export * from "./SupportCasesView";
|
||||||
|
export * from "./SupportCaseDetailView";
|
||||||
|
export * from "./SupportHomeView";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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";
|
|
||||||
}
|
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
76
packages/domain/auth/forms.ts
Normal file
76
packages/domain/auth/forms.ts
Normal 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;
|
||||||
@ -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";
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
479
packages/domain/common/errors.ts
Normal file
479
packages/domain/common/errors.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -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";
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
export * from "./contract";
|
export * from "./contract";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
export * from "./utils";
|
||||||
|
|||||||
104
packages/domain/dashboard/utils.ts
Normal file
104
packages/domain/dashboard/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
18
packages/domain/support/providers/index.ts
Normal file
18
packages/domain/support/providers/index.ts
Normal 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";
|
||||||
|
|
||||||
7
packages/domain/support/providers/salesforce/index.ts
Normal file
7
packages/domain/support/providers/salesforce/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Support Domain - Salesforce Provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./raw.types";
|
||||||
|
export * from "./mapper";
|
||||||
|
|
||||||
170
packages/domain/support/providers/salesforce/mapper.ts
Normal file
170
packages/domain/support/providers/salesforce/mapper.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
322
packages/domain/support/providers/salesforce/raw.types.ts
Normal file
322
packages/domain/support/providers/salesforce/raw.types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user