feat: add VPN services and call history management features
- Implemented VpnServicesService for managing VPN plans and activation fees. - Created SimCallHistoryFormatterService for formatting call history data. - Developed SimCallHistoryParserService to parse call history CSV files. - Added AnimatedContainer and AnimatedBackground components for UI animations. - Introduced BentoServiceCard, FloatingGlassCard, GlowButton, and ValuePropCard components for landing page. - Implemented useCountUp hook for animated number counting. - Added cancellation months utility functions for subscription management.
This commit is contained in:
parent
4dd4278677
commit
bde9f706ce
24
apps/bff/src/core/errors/index.ts
Normal file
24
apps/bff/src/core/errors/index.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Core Error Utilities
|
||||||
|
*
|
||||||
|
* Shared error handling utilities for the BFF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// User-friendly message utilities
|
||||||
|
export {
|
||||||
|
matchCommonError,
|
||||||
|
getUserFriendlyMessage,
|
||||||
|
isTimeoutError,
|
||||||
|
isNetworkError,
|
||||||
|
isAuthError,
|
||||||
|
isRateLimitError,
|
||||||
|
isPermissionError,
|
||||||
|
getTimeoutMessage,
|
||||||
|
getNetworkMessage,
|
||||||
|
getAuthMessage,
|
||||||
|
getRateLimitMessage,
|
||||||
|
getPermissionMessage,
|
||||||
|
getDefaultMessage,
|
||||||
|
type ErrorMatchResult,
|
||||||
|
type ErrorCategory,
|
||||||
|
} from "./user-friendly-messages.js";
|
||||||
232
apps/bff/src/core/errors/user-friendly-messages.ts
Normal file
232
apps/bff/src/core/errors/user-friendly-messages.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* User-Friendly Error Messages
|
||||||
|
*
|
||||||
|
* Common error pattern matchers and message generators that can be composed
|
||||||
|
* with domain-specific error handlers.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* - Use `matchCommonError()` for generic network/timeout/auth errors
|
||||||
|
* - Use domain-specific handlers for business logic errors
|
||||||
|
* - Compose both for complete error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common error pattern result
|
||||||
|
*/
|
||||||
|
export interface ErrorMatchResult {
|
||||||
|
matched: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error pattern category for context-aware messages
|
||||||
|
*/
|
||||||
|
export type ErrorCategory = "generic" | "sim" | "payment" | "billing" | "support";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error message matches a timeout pattern
|
||||||
|
*/
|
||||||
|
export function isTimeoutError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return lowerMessage.includes("timeout") || lowerMessage.includes("timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error message matches a network/connection pattern
|
||||||
|
*/
|
||||||
|
export function isNetworkError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("network") ||
|
||||||
|
lowerMessage.includes("connection") ||
|
||||||
|
lowerMessage.includes("econnrefused") ||
|
||||||
|
lowerMessage.includes("econnreset") ||
|
||||||
|
lowerMessage.includes("socket hang up") ||
|
||||||
|
lowerMessage.includes("fetch failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error message matches an authentication pattern
|
||||||
|
*/
|
||||||
|
export function isAuthError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("unauthorized") ||
|
||||||
|
lowerMessage.includes("forbidden") ||
|
||||||
|
lowerMessage.includes("authentication failed") ||
|
||||||
|
lowerMessage.includes("403") ||
|
||||||
|
lowerMessage.includes("401")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error message matches a rate limit pattern
|
||||||
|
*/
|
||||||
|
export function isRateLimitError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("rate limit") ||
|
||||||
|
lowerMessage.includes("too many requests") ||
|
||||||
|
lowerMessage.includes("429")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error message matches a permission error
|
||||||
|
*/
|
||||||
|
export function isPermissionError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return lowerMessage.includes("invalid permissions") || lowerMessage.includes("not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware message for timeout errors
|
||||||
|
*/
|
||||||
|
export function getTimeoutMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM service request timed out. Please try again.";
|
||||||
|
case "payment":
|
||||||
|
return "Payment processing timed out. Please try again.";
|
||||||
|
default:
|
||||||
|
return "The request timed out. Please try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware message for network errors
|
||||||
|
*/
|
||||||
|
export function getNetworkMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM service request timed out. Please try again.";
|
||||||
|
case "payment":
|
||||||
|
return "Payment processing timed out. Please try again.";
|
||||||
|
default:
|
||||||
|
return "Network error. Please check your connection and try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware message for auth errors
|
||||||
|
*/
|
||||||
|
export function getAuthMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
|
case "payment":
|
||||||
|
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
default:
|
||||||
|
return "Service is temporarily unavailable. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware message for rate limit errors
|
||||||
|
*/
|
||||||
|
export function getRateLimitMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM service is busy. Please wait a moment and try again.";
|
||||||
|
case "payment":
|
||||||
|
return "Payment service is busy. Please wait a moment and try again.";
|
||||||
|
default:
|
||||||
|
return "Service is busy. Please wait a moment and try again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get context-aware message for permission errors
|
||||||
|
*/
|
||||||
|
export function getPermissionMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
case "payment":
|
||||||
|
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
default:
|
||||||
|
return "This operation is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default fallback message for category
|
||||||
|
*/
|
||||||
|
export function getDefaultMessage(category: ErrorCategory = "generic"): string {
|
||||||
|
switch (category) {
|
||||||
|
case "sim":
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
case "payment":
|
||||||
|
return "Unable to process payment. Please try again or contact support.";
|
||||||
|
case "billing":
|
||||||
|
return "Billing operation failed. Please try again or contact support.";
|
||||||
|
case "support":
|
||||||
|
return "Unable to complete request. Please try again or contact support.";
|
||||||
|
default:
|
||||||
|
return "An unexpected error occurred. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match common error patterns and return appropriate user-friendly message.
|
||||||
|
*
|
||||||
|
* @param errorMessage - The technical error message to match
|
||||||
|
* @param category - Context category for appropriate messaging
|
||||||
|
* @returns Match result with whether pattern matched and the message
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const result = matchCommonError(error.message, "sim");
|
||||||
|
* if (result.matched) {
|
||||||
|
* return result.message;
|
||||||
|
* }
|
||||||
|
* // Fall through to domain-specific error handling
|
||||||
|
*/
|
||||||
|
export function matchCommonError(
|
||||||
|
errorMessage: string,
|
||||||
|
category: ErrorCategory = "generic"
|
||||||
|
): ErrorMatchResult {
|
||||||
|
if (!errorMessage) {
|
||||||
|
return { matched: false, message: getDefaultMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check patterns in order of specificity
|
||||||
|
if (isRateLimitError(errorMessage)) {
|
||||||
|
return { matched: true, message: getRateLimitMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthError(errorMessage)) {
|
||||||
|
return { matched: true, message: getAuthMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPermissionError(errorMessage)) {
|
||||||
|
return { matched: true, message: getPermissionMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTimeoutError(errorMessage)) {
|
||||||
|
return { matched: true, message: getTimeoutMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNetworkError(errorMessage)) {
|
||||||
|
return { matched: true, message: getNetworkMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matched: false, message: getDefaultMessage(category) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly message for any error, with fallback to default.
|
||||||
|
*
|
||||||
|
* This is a convenience function that always returns a user-friendly message.
|
||||||
|
* Use this when you don't need to check if a common pattern matched.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* return getUserFriendlyMessage(error.message, "payment");
|
||||||
|
*/
|
||||||
|
export function getUserFriendlyMessage(
|
||||||
|
errorMessage: string,
|
||||||
|
category: ErrorCategory = "generic"
|
||||||
|
): string {
|
||||||
|
const result = matchCommonError(errorMessage, category);
|
||||||
|
return result.message;
|
||||||
|
}
|
||||||
67
apps/bff/src/core/utils/array.util.ts
Normal file
67
apps/bff/src/core/utils/array.util.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Array Utilities
|
||||||
|
*
|
||||||
|
* Common array manipulation functions used across the BFF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split an array into chunks of specified size.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* chunkArray([1, 2, 3, 4, 5], 2) // [[1, 2], [3, 4], [5]]
|
||||||
|
* chunkArray(['a', 'b', 'c'], 3) // [['a', 'b', 'c']]
|
||||||
|
*/
|
||||||
|
export function chunkArray<T>(array: T[], size: number): T[][] {
|
||||||
|
if (size <= 0) {
|
||||||
|
throw new Error("Chunk size must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: T[][] = [];
|
||||||
|
for (let i = 0; i < array.length; i += size) {
|
||||||
|
chunks.push(array.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group array items by a key function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* groupBy([{type: 'a', val: 1}, {type: 'b', val: 2}, {type: 'a', val: 3}], x => x.type)
|
||||||
|
* // { a: [{type: 'a', val: 1}, {type: 'a', val: 3}], b: [{type: 'b', val: 2}] }
|
||||||
|
*/
|
||||||
|
export function groupBy<T, K extends string | number>(
|
||||||
|
array: T[],
|
||||||
|
keyFn: (item: T) => K
|
||||||
|
): Record<K, T[]> {
|
||||||
|
return array.reduce(
|
||||||
|
(result, item) => {
|
||||||
|
const key = keyFn(item);
|
||||||
|
if (!result[key]) {
|
||||||
|
result[key] = [];
|
||||||
|
}
|
||||||
|
result[key].push(item);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{} as Record<K, T[]>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove duplicate items from an array based on a key function.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* uniqueBy([{id: 1, name: 'a'}, {id: 1, name: 'b'}, {id: 2, name: 'c'}], x => x.id)
|
||||||
|
* // [{id: 1, name: 'a'}, {id: 2, name: 'c'}]
|
||||||
|
*/
|
||||||
|
export function uniqueBy<T, K>(array: T[], keyFn: (item: T) => K): T[] {
|
||||||
|
const seen = new Set<K>();
|
||||||
|
return array.filter(item => {
|
||||||
|
const key = keyFn(item);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
30
apps/bff/src/core/utils/index.ts
Normal file
30
apps/bff/src/core/utils/index.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Core Utilities
|
||||||
|
*
|
||||||
|
* Shared utility functions used across the BFF.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Array utilities
|
||||||
|
export { chunkArray, groupBy, uniqueBy } from "./array.util.js";
|
||||||
|
|
||||||
|
// Error utilities
|
||||||
|
export { extractErrorMessage } from "./error.util.js";
|
||||||
|
export { withErrorHandling, withErrorSuppression, withErrorLogging } from "./error-handler.util.js";
|
||||||
|
|
||||||
|
// Retry utilities
|
||||||
|
export {
|
||||||
|
withRetry,
|
||||||
|
withRetrySafe,
|
||||||
|
sleep,
|
||||||
|
calculateBackoffDelay,
|
||||||
|
getRateLimitDelay,
|
||||||
|
RetryableErrors,
|
||||||
|
type RetryOptions,
|
||||||
|
type RetryResult,
|
||||||
|
} from "./retry.util.js";
|
||||||
|
|
||||||
|
// Validation utilities
|
||||||
|
export { parseUuidOrThrow } from "./validation.util.js";
|
||||||
|
|
||||||
|
// SSO utilities
|
||||||
|
export { sanitizeWhmcsRedirectPath } from "./sso.util.js";
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service.js";
|
|
||||||
import { FreebitMapperService } from "./services/freebit-mapper.service.js";
|
import { FreebitMapperService } from "./services/freebit-mapper.service.js";
|
||||||
import { FreebitOperationsService } from "./services/freebit-operations.service.js";
|
import { FreebitOperationsService } from "./services/freebit-operations.service.js";
|
||||||
import { FreebitClientService } from "./services/freebit-client.service.js";
|
import { FreebitClientService } from "./services/freebit-client.service.js";
|
||||||
@ -11,12 +10,14 @@ import { FreebitPlanService } from "./services/freebit-plan.service.js";
|
|||||||
import { FreebitVoiceService } from "./services/freebit-voice.service.js";
|
import { FreebitVoiceService } from "./services/freebit-voice.service.js";
|
||||||
import { FreebitCancellationService } from "./services/freebit-cancellation.service.js";
|
import { FreebitCancellationService } from "./services/freebit-cancellation.service.js";
|
||||||
import { FreebitEsimService } from "./services/freebit-esim.service.js";
|
import { FreebitEsimService } from "./services/freebit-esim.service.js";
|
||||||
|
import { FreebitErrorHandlerService } from "./services/freebit-error-handler.service.js";
|
||||||
import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js";
|
import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [VoiceOptionsModule],
|
imports: [VoiceOptionsModule],
|
||||||
providers: [
|
providers: [
|
||||||
// Core services
|
// Core services
|
||||||
|
FreebitErrorHandlerService,
|
||||||
FreebitClientService,
|
FreebitClientService,
|
||||||
FreebitAuthService,
|
FreebitAuthService,
|
||||||
FreebitMapperService,
|
FreebitMapperService,
|
||||||
@ -28,14 +29,13 @@ import { VoiceOptionsModule } from "../../modules/voice-options/voice-options.mo
|
|||||||
FreebitVoiceService,
|
FreebitVoiceService,
|
||||||
FreebitCancellationService,
|
FreebitCancellationService,
|
||||||
FreebitEsimService,
|
FreebitEsimService,
|
||||||
// Facade (delegates to specialized services)
|
// Facade (delegates to specialized services, handles account normalization)
|
||||||
FreebitOperationsService,
|
FreebitOperationsService,
|
||||||
FreebitOrchestratorService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
// Export orchestrator for high-level operations
|
// Export error handler
|
||||||
FreebitOrchestratorService,
|
FreebitErrorHandlerService,
|
||||||
// Export facade for backward compatibility
|
// Export main facade for all Freebit operations
|
||||||
FreebitOperationsService,
|
FreebitOperationsService,
|
||||||
// Export specialized services for direct access if needed
|
// Export specialized services for direct access if needed
|
||||||
FreebitAccountService,
|
FreebitAccountService,
|
||||||
|
|||||||
@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, HttpStatus } from "@nestjs/common";
|
||||||
|
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
|
||||||
|
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import { matchCommonError } from "@bff/core/errors/index.js";
|
||||||
|
import { FreebitError } from "./freebit-error.service.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling and normalizing Freebit API errors.
|
||||||
|
* Maps Freebit errors to appropriate NestJS exceptions.
|
||||||
|
*
|
||||||
|
* Mirrors the pattern used by WhmcsErrorHandlerService and SalesforceErrorHandlerService.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitErrorHandlerService {
|
||||||
|
/**
|
||||||
|
* Handle Freebit API error (from FreebitError or raw error)
|
||||||
|
*/
|
||||||
|
handleApiError(error: unknown, context: string): never {
|
||||||
|
if (error instanceof FreebitError) {
|
||||||
|
const mapped = this.mapFreebitErrorToDomain(error);
|
||||||
|
throw new DomainHttpException(mapped.code, mapped.status, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle generic errors
|
||||||
|
this.handleRequestError(error, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle general request errors (network, timeout, etc.)
|
||||||
|
*/
|
||||||
|
handleRequestError(error: unknown, context: string): never {
|
||||||
|
const message = extractErrorMessage(error);
|
||||||
|
|
||||||
|
// Check for timeout
|
||||||
|
if (this.isTimeoutError(message)) {
|
||||||
|
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network errors
|
||||||
|
if (this.isNetworkError(message)) {
|
||||||
|
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (this.isRateLimitError(message)) {
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.RATE_LIMITED,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"Freebit rate limit exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for auth errors
|
||||||
|
if (this.isAuthError(message)) {
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Freebit authentication failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw if already a DomainHttpException
|
||||||
|
if (error instanceof DomainHttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap unknown errors
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
`Freebit ${context} failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map FreebitError to domain error codes
|
||||||
|
*/
|
||||||
|
private mapFreebitErrorToDomain(error: FreebitError): {
|
||||||
|
code: ErrorCodeType;
|
||||||
|
status: HttpStatus;
|
||||||
|
} {
|
||||||
|
const resultCode = String(error.resultCode || "");
|
||||||
|
const statusCode = String(error.statusCode || "");
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
if (error.isAuthError()) {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit errors
|
||||||
|
if (error.isRateLimitError()) {
|
||||||
|
return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found errors
|
||||||
|
if (message.includes("account not found") || message.includes("no such account")) {
|
||||||
|
return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan change specific errors
|
||||||
|
if (resultCode === "215" || statusCode === "215") {
|
||||||
|
return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network type change errors
|
||||||
|
if (
|
||||||
|
resultCode === "381" ||
|
||||||
|
statusCode === "381" ||
|
||||||
|
resultCode === "382" ||
|
||||||
|
statusCode === "382"
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server errors (retryable)
|
||||||
|
if (error.isRetryable() && !this.isTimeoutError(error.message)) {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
if (this.isTimeoutError(error.message)) {
|
||||||
|
return { code: ErrorCode.TIMEOUT, status: HttpStatus.GATEWAY_TIMEOUT };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: external service error
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if message indicates timeout
|
||||||
|
*/
|
||||||
|
private isTimeoutError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return lowerMessage.includes("timeout") || lowerMessage.includes("aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if message indicates network error
|
||||||
|
*/
|
||||||
|
private isNetworkError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("network") ||
|
||||||
|
lowerMessage.includes("connection") ||
|
||||||
|
lowerMessage.includes("econnrefused") ||
|
||||||
|
lowerMessage.includes("fetch failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if message indicates rate limit
|
||||||
|
*/
|
||||||
|
private isRateLimitError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("rate limit") ||
|
||||||
|
lowerMessage.includes("too many requests") ||
|
||||||
|
lowerMessage.includes("429")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if message indicates auth error
|
||||||
|
*/
|
||||||
|
private isAuthError(message: string): boolean {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerMessage.includes("authentication") ||
|
||||||
|
lowerMessage.includes("unauthorized") ||
|
||||||
|
lowerMessage.includes("401")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly error message for Freebit errors
|
||||||
|
*/
|
||||||
|
getUserFriendlyMessage(error: unknown): string {
|
||||||
|
// Use FreebitError's built-in method if available
|
||||||
|
if (error instanceof FreebitError) {
|
||||||
|
return error.getUserFriendlyMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to common error patterns
|
||||||
|
const message = extractErrorMessage(error);
|
||||||
|
const commonResult = matchCommonError(message, "sim");
|
||||||
|
|
||||||
|
if (commonResult.matched) {
|
||||||
|
return commonResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { FreebitPlanService } from "./freebit-plan.service.js";
|
|||||||
import { FreebitVoiceService } from "./freebit-voice.service.js";
|
import { FreebitVoiceService } from "./freebit-voice.service.js";
|
||||||
import { FreebitCancellationService } from "./freebit-cancellation.service.js";
|
import { FreebitCancellationService } from "./freebit-cancellation.service.js";
|
||||||
import { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js";
|
import { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js";
|
||||||
|
import { FreebitMapperService } from "./freebit-mapper.service.js";
|
||||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
|
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,6 +14,9 @@ import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebi
|
|||||||
* Unified interface for all Freebit SIM operations.
|
* Unified interface for all Freebit SIM operations.
|
||||||
* Delegates to specialized services for each operation type.
|
* Delegates to specialized services for each operation type.
|
||||||
*
|
*
|
||||||
|
* This service handles account normalization automatically, so consumers
|
||||||
|
* don't need to worry about formatting phone numbers correctly.
|
||||||
|
*
|
||||||
* Services:
|
* Services:
|
||||||
* - FreebitAccountService: SIM details, health checks
|
* - FreebitAccountService: SIM details, health checks
|
||||||
* - FreebitUsageService: Usage queries, top-ups, quota history
|
* - FreebitUsageService: Usage queries, top-ups, quota history
|
||||||
@ -29,9 +33,17 @@ export class FreebitOperationsService {
|
|||||||
private readonly planService: FreebitPlanService,
|
private readonly planService: FreebitPlanService,
|
||||||
private readonly voiceService: FreebitVoiceService,
|
private readonly voiceService: FreebitVoiceService,
|
||||||
private readonly cancellationService: FreebitCancellationService,
|
private readonly cancellationService: FreebitCancellationService,
|
||||||
private readonly esimService: FreebitEsimService
|
private readonly esimService: FreebitEsimService,
|
||||||
|
private readonly mapper: FreebitMapperService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize account identifier (remove formatting like dashes, spaces)
|
||||||
|
*/
|
||||||
|
private normalizeAccount(account: string): string {
|
||||||
|
return this.mapper.normalizeAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Account Operations (delegated to FreebitAccountService)
|
// Account Operations (delegated to FreebitAccountService)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -40,7 +52,8 @@ export class FreebitOperationsService {
|
|||||||
* Get SIM account details with endpoint fallback
|
* Get SIM account details with endpoint fallback
|
||||||
*/
|
*/
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
return this.accountService.getSimDetails(account);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.accountService.getSimDetails(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -58,7 +71,8 @@ export class FreebitOperationsService {
|
|||||||
* Get SIM usage/traffic information
|
* Get SIM usage/traffic information
|
||||||
*/
|
*/
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
return this.usageService.getSimUsage(account);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.usageService.getSimUsage(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -69,7 +83,8 @@ export class FreebitOperationsService {
|
|||||||
quotaMb: number,
|
quotaMb: number,
|
||||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.usageService.topUpSim(account, quotaMb, options);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.usageService.topUpSim(normalizedAccount, quotaMb, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,7 +95,8 @@ export class FreebitOperationsService {
|
|||||||
fromDate: string,
|
fromDate: string,
|
||||||
toDate: string
|
toDate: string
|
||||||
): Promise<SimTopUpHistory> {
|
): Promise<SimTopUpHistory> {
|
||||||
return this.usageService.getSimTopUpHistory(account, fromDate, toDate);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.usageService.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -96,7 +112,8 @@ export class FreebitOperationsService {
|
|||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
return this.planService.changeSimPlan(account, newPlanCode, options);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.planService.changeSimPlan(normalizedAccount, newPlanCode, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -115,7 +132,8 @@ export class FreebitOperationsService {
|
|||||||
networkType?: "4G" | "5G";
|
networkType?: "4G" | "5G";
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.voiceService.updateSimFeatures(account, features);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.voiceService.updateSimFeatures(normalizedAccount, features);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -126,14 +144,16 @@ export class FreebitOperationsService {
|
|||||||
* Cancel SIM plan (PA05-04 - plan cancellation only)
|
* Cancel SIM plan (PA05-04 - plan cancellation only)
|
||||||
*/
|
*/
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
return this.cancellationService.cancelSim(account, scheduledAt);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.cancellationService.cancelSim(normalizedAccount, scheduledAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel SIM account (PA02-04 - full account cancellation)
|
* Cancel SIM account (PA02-04 - full account cancellation)
|
||||||
*/
|
*/
|
||||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||||
return this.cancellationService.cancelAccount(account, runDate);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.cancellationService.cancelAccount(normalizedAccount, runDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -144,7 +164,8 @@ export class FreebitOperationsService {
|
|||||||
* Reissue eSIM profile (simple version)
|
* Reissue eSIM profile (simple version)
|
||||||
*/
|
*/
|
||||||
async reissueEsimProfile(account: string): Promise<void> {
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
return this.esimService.reissueEsimProfile(account);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.esimService.reissueEsimProfile(normalizedAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -155,13 +176,18 @@ export class FreebitOperationsService {
|
|||||||
newEid: string,
|
newEid: string,
|
||||||
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.esimService.reissueEsimProfileEnhanced(account, newEid, options);
|
const normalizedAccount = this.normalizeAccount(account);
|
||||||
|
return this.esimService.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate new eSIM account using PA05-41 (addAcct)
|
* Activate new eSIM account using PA05-41 (addAcct)
|
||||||
*/
|
*/
|
||||||
async activateEsimAccountNew(params: EsimActivationParams): Promise<void> {
|
async activateEsimAccountNew(params: EsimActivationParams): Promise<void> {
|
||||||
return this.esimService.activateEsimAccountNew(params);
|
const normalizedParams = {
|
||||||
|
...params,
|
||||||
|
account: this.normalizeAccount(params.account),
|
||||||
|
};
|
||||||
|
return this.esimService.activateEsimAccountNew(normalizedParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,166 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { FreebitOperationsService } from "./freebit-operations.service.js";
|
|
||||||
import { FreebitMapperService } from "./freebit-mapper.service.js";
|
|
||||||
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types.js";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FreebitOrchestratorService {
|
|
||||||
constructor(
|
|
||||||
private readonly operations: FreebitOperationsService,
|
|
||||||
private readonly mapper: FreebitMapperService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM account details
|
|
||||||
*/
|
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.getSimDetails(normalizedAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM usage information
|
|
||||||
*/
|
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.getSimUsage(normalizedAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Top up SIM data quota
|
|
||||||
*/
|
|
||||||
async topUpSim(
|
|
||||||
account: string,
|
|
||||||
quotaMb: number,
|
|
||||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
|
||||||
): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.topUpSim(normalizedAccount, quotaMb, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM top-up history
|
|
||||||
*/
|
|
||||||
async getSimTopUpHistory(
|
|
||||||
account: string,
|
|
||||||
fromDate: string,
|
|
||||||
toDate: string
|
|
||||||
): Promise<SimTopUpHistory> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change SIM plan
|
|
||||||
*/
|
|
||||||
async changeSimPlan(
|
|
||||||
account: string,
|
|
||||||
newPlanCode: string,
|
|
||||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update SIM features
|
|
||||||
*/
|
|
||||||
async updateSimFeatures(
|
|
||||||
account: string,
|
|
||||||
features: {
|
|
||||||
voiceMailEnabled?: boolean;
|
|
||||||
callWaitingEnabled?: boolean;
|
|
||||||
internationalRoamingEnabled?: boolean;
|
|
||||||
networkType?: "4G" | "5G";
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.updateSimFeatures(normalizedAccount, features);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM service (plan cancellation - PA05-04)
|
|
||||||
*/
|
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM account (full account cancellation - PA02-04)
|
|
||||||
*/
|
|
||||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.cancelAccount(normalizedAccount, runDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile (simple)
|
|
||||||
*/
|
|
||||||
async reissueEsimProfile(account: string): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.reissueEsimProfile(normalizedAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile with enhanced options
|
|
||||||
*/
|
|
||||||
async reissueEsimProfileEnhanced(
|
|
||||||
account: string,
|
|
||||||
newEid: string,
|
|
||||||
options: { oldEid?: string; planCode?: string } = {}
|
|
||||||
): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(account);
|
|
||||||
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate new eSIM account
|
|
||||||
*/
|
|
||||||
async activateEsimAccountNew(params: {
|
|
||||||
account: string;
|
|
||||||
eid: string;
|
|
||||||
planCode?: string;
|
|
||||||
contractLine?: "4G" | "5G";
|
|
||||||
aladinOperated?: "10" | "20";
|
|
||||||
shipDate?: string;
|
|
||||||
addKind?: "N" | "M" | "R";
|
|
||||||
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり
|
|
||||||
repAccount?: string;
|
|
||||||
deliveryCode?: string;
|
|
||||||
globalIp?: "10" | "20";
|
|
||||||
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
|
||||||
identity?: {
|
|
||||||
firstnameKanji?: string;
|
|
||||||
lastnameKanji?: string;
|
|
||||||
firstnameZenKana?: string;
|
|
||||||
lastnameZenKana?: string;
|
|
||||||
gender?: string;
|
|
||||||
birthday?: string;
|
|
||||||
};
|
|
||||||
}): Promise<void> {
|
|
||||||
const normalizedAccount = this.mapper.normalizeAccount(params.account);
|
|
||||||
return this.operations.activateEsimAccountNew({
|
|
||||||
account: normalizedAccount,
|
|
||||||
eid: params.eid,
|
|
||||||
planCode: params.planCode,
|
|
||||||
contractLine: params.contractLine,
|
|
||||||
aladinOperated: params.aladinOperated,
|
|
||||||
shipDate: params.shipDate,
|
|
||||||
addKind: params.addKind,
|
|
||||||
simKind: params.simKind,
|
|
||||||
repAccount: params.repAccount,
|
|
||||||
deliveryCode: params.deliveryCode,
|
|
||||||
globalIp: params.globalIp,
|
|
||||||
mnp: params.mnp,
|
|
||||||
identity: params.identity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check
|
|
||||||
*/
|
|
||||||
async healthCheck(): Promise<boolean> {
|
|
||||||
return this.operations.healthCheck();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
// Export all Freebit services
|
// Export all Freebit services
|
||||||
export { FreebitOrchestratorService } from "./freebit-orchestrator.service.js";
|
|
||||||
export { FreebitMapperService } from "./freebit-mapper.service.js";
|
export { FreebitMapperService } from "./freebit-mapper.service.js";
|
||||||
export { FreebitOperationsService } from "./freebit-operations.service.js";
|
export { FreebitOperationsService } from "./freebit-operations.service.js";
|
||||||
export { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
|
export { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js";
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import {
|
|||||||
extractPayload,
|
extractPayload,
|
||||||
extractStringField,
|
extractStringField,
|
||||||
} from "./shared/index.js";
|
} from "./shared/index.js";
|
||||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js";
|
||||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
|
import { AccountNotificationHandler } from "@bff/modules/notifications/account-cdc-listener.service.js";
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import {
|
|||||||
extractStringField,
|
extractStringField,
|
||||||
extractRecordIds,
|
extractRecordIds,
|
||||||
} from "./shared/index.js";
|
} from "./shared/index.js";
|
||||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js";
|
||||||
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
import { RealtimeService } from "@bff/infra/realtime/realtime.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -14,10 +14,12 @@ import { SalesforceWriteThrottleGuard } from "./guards/salesforce-write-throttle
|
|||||||
import { OpportunityQueryService } from "./services/opportunity/opportunity-query.service.js";
|
import { OpportunityQueryService } from "./services/opportunity/opportunity-query.service.js";
|
||||||
import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js";
|
import { OpportunityCancellationService } from "./services/opportunity/opportunity-cancellation.service.js";
|
||||||
import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js";
|
import { OpportunityMutationService } from "./services/opportunity/opportunity-mutation.service.js";
|
||||||
|
import { SalesforceErrorHandlerService } from "./services/salesforce-error-handler.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
|
imports: [QueueModule, ConfigModule, SalesforceOrderFieldConfigModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
SalesforceErrorHandlerService,
|
||||||
SalesforceConnection,
|
SalesforceConnection,
|
||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
SalesforceOrderService,
|
SalesforceOrderService,
|
||||||
@ -35,6 +37,7 @@ import { OpportunityMutationService } from "./services/opportunity/opportunity-m
|
|||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
QueueModule,
|
QueueModule,
|
||||||
|
SalesforceErrorHandlerService,
|
||||||
SalesforceService,
|
SalesforceService,
|
||||||
SalesforceConnection,
|
SalesforceConnection,
|
||||||
SalesforceAccountService,
|
SalesforceAccountService,
|
||||||
|
|||||||
@ -0,0 +1,226 @@
|
|||||||
|
import { Injectable, HttpStatus } from "@nestjs/common";
|
||||||
|
import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common";
|
||||||
|
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import { matchCommonError } from "@bff/core/errors/index.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salesforce error response structure
|
||||||
|
*/
|
||||||
|
export interface SalesforceErrorResponse {
|
||||||
|
errorCode?: string;
|
||||||
|
message?: string;
|
||||||
|
fields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling and normalizing Salesforce API errors.
|
||||||
|
* Maps Salesforce errors to appropriate NestJS exceptions.
|
||||||
|
*
|
||||||
|
* Mirrors the pattern used by WhmcsErrorHandlerService for consistency.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SalesforceErrorHandlerService {
|
||||||
|
/**
|
||||||
|
* Handle Salesforce API error response
|
||||||
|
*/
|
||||||
|
handleApiError(
|
||||||
|
errorResponse: SalesforceErrorResponse | SalesforceErrorResponse[],
|
||||||
|
context: string
|
||||||
|
): never {
|
||||||
|
// Salesforce can return an array of errors
|
||||||
|
const errors = Array.isArray(errorResponse) ? errorResponse : [errorResponse];
|
||||||
|
const firstError = errors[0] || {};
|
||||||
|
|
||||||
|
const errorCode = firstError.errorCode || "UNKNOWN_ERROR";
|
||||||
|
const message = firstError.message || "Salesforce operation failed";
|
||||||
|
|
||||||
|
const mapped = this.mapSalesforceErrorToDomain(errorCode, message, context);
|
||||||
|
throw new DomainHttpException(mapped.code, mapped.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle general request errors (network, timeout, session expired, etc.)
|
||||||
|
*/
|
||||||
|
handleRequestError(error: unknown, context: string): never {
|
||||||
|
// Check for session expired
|
||||||
|
if (this.isSessionExpiredError(error)) {
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"Salesforce session expired"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for timeout
|
||||||
|
if (this.isTimeoutError(error)) {
|
||||||
|
throw new DomainHttpException(ErrorCode.TIMEOUT, HttpStatus.GATEWAY_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for network errors
|
||||||
|
if (this.isNetworkError(error)) {
|
||||||
|
throw new DomainHttpException(ErrorCode.NETWORK_ERROR, HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for rate limiting
|
||||||
|
if (this.isRateLimitError(error)) {
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.RATE_LIMITED,
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
"Salesforce rate limit exceeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-throw if already a DomainHttpException
|
||||||
|
if (error instanceof DomainHttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap unknown errors
|
||||||
|
throw new DomainHttpException(
|
||||||
|
ErrorCode.EXTERNAL_SERVICE_ERROR,
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
`Salesforce ${context} failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Salesforce error codes to domain error codes
|
||||||
|
*/
|
||||||
|
private mapSalesforceErrorToDomain(
|
||||||
|
sfErrorCode: string,
|
||||||
|
message: string,
|
||||||
|
context: string
|
||||||
|
): { code: ErrorCodeType; status: HttpStatus } {
|
||||||
|
// Not found errors
|
||||||
|
if (
|
||||||
|
sfErrorCode === "NOT_FOUND" ||
|
||||||
|
sfErrorCode === "INVALID_CROSS_REFERENCE_KEY" ||
|
||||||
|
message.toLowerCase().includes("no rows")
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.NOT_FOUND, status: HttpStatus.NOT_FOUND };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication errors
|
||||||
|
if (
|
||||||
|
sfErrorCode === "INVALID_SESSION_ID" ||
|
||||||
|
sfErrorCode === "SESSION_EXPIRED" ||
|
||||||
|
sfErrorCode === "INVALID_AUTH_HEADER"
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
if (
|
||||||
|
sfErrorCode === "REQUIRED_FIELD_MISSING" ||
|
||||||
|
sfErrorCode === "INVALID_FIELD" ||
|
||||||
|
sfErrorCode === "MALFORMED_ID" ||
|
||||||
|
sfErrorCode === "FIELD_CUSTOM_VALIDATION_EXCEPTION" ||
|
||||||
|
sfErrorCode === "FIELD_INTEGRITY_EXCEPTION"
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.VALIDATION_FAILED, status: HttpStatus.BAD_REQUEST };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate detection
|
||||||
|
if (sfErrorCode === "DUPLICATE_VALUE" || sfErrorCode === "DUPLICATE_EXTERNAL_ID") {
|
||||||
|
return { code: ErrorCode.ACCOUNT_EXISTS, status: HttpStatus.CONFLICT };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insufficient access
|
||||||
|
if (
|
||||||
|
sfErrorCode === "INSUFFICIENT_ACCESS" ||
|
||||||
|
sfErrorCode === "INSUFFICIENT_ACCESS_OR_READONLY" ||
|
||||||
|
sfErrorCode === "CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY"
|
||||||
|
) {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.FORBIDDEN };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (sfErrorCode === "REQUEST_LIMIT_EXCEEDED" || sfErrorCode === "TOO_MANY_REQUESTS") {
|
||||||
|
return { code: ErrorCode.RATE_LIMITED, status: HttpStatus.TOO_MANY_REQUESTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage limit
|
||||||
|
if (sfErrorCode === "STORAGE_LIMIT_EXCEEDED") {
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: external service error
|
||||||
|
void context; // reserved for future use
|
||||||
|
return { code: ErrorCode.EXTERNAL_SERVICE_ERROR, status: HttpStatus.BAD_GATEWAY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a session expired error
|
||||||
|
*/
|
||||||
|
isSessionExpiredError(error: unknown): boolean {
|
||||||
|
const message = extractErrorMessage(error).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("session expired") ||
|
||||||
|
message.includes("invalid session") ||
|
||||||
|
message.includes("invalid_session_id")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a timeout error
|
||||||
|
*/
|
||||||
|
isTimeoutError(error: unknown): boolean {
|
||||||
|
const message = extractErrorMessage(error).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("timeout") ||
|
||||||
|
message.includes("aborted") ||
|
||||||
|
(error instanceof Error && error.name === "AbortError")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a network error
|
||||||
|
*/
|
||||||
|
isNetworkError(error: unknown): boolean {
|
||||||
|
const message = extractErrorMessage(error).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("network") ||
|
||||||
|
message.includes("connection") ||
|
||||||
|
message.includes("econnrefused") ||
|
||||||
|
message.includes("enotfound") ||
|
||||||
|
message.includes("fetch failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a rate limit error
|
||||||
|
*/
|
||||||
|
isRateLimitError(error: unknown): boolean {
|
||||||
|
const message = extractErrorMessage(error).toLowerCase();
|
||||||
|
return (
|
||||||
|
message.includes("rate limit") ||
|
||||||
|
message.includes("too many requests") ||
|
||||||
|
message.includes("request_limit_exceeded")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly error message
|
||||||
|
*/
|
||||||
|
getUserFriendlyMessage(error: unknown): string {
|
||||||
|
const message = extractErrorMessage(error);
|
||||||
|
|
||||||
|
// Try common error patterns first
|
||||||
|
const commonResult = matchCommonError(message, "generic");
|
||||||
|
if (commonResult.matched) {
|
||||||
|
return commonResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Salesforce-specific messages
|
||||||
|
if (this.isSessionExpiredError(error)) {
|
||||||
|
return "Service connection expired. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.toLowerCase().includes("no rows")) {
|
||||||
|
return "The requested record was not found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "An unexpected error occurred. Please try again later.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { chunkArray, sleep, extractErrorMessage } from "@bff/core/utils/index.js";
|
||||||
|
import { matchCommonError, getDefaultMessage } from "@bff/core/errors/index.js";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||||
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
||||||
@ -113,7 +114,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
// Batch the invoice detail fetches to avoid N+1 overwhelming the API
|
// Batch the invoice detail fetches to avoid N+1 overwhelming the API
|
||||||
const invoicesWithItems: Invoice[] = [];
|
const invoicesWithItems: Invoice[] = [];
|
||||||
const batches = this.chunkArray(invoiceList.invoices, BATCH_SIZE);
|
const batches = chunkArray(invoiceList.invoices, BATCH_SIZE);
|
||||||
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
for (let i = 0; i < batches.length; i++) {
|
||||||
const batch = batches[i];
|
const batch = batches[i];
|
||||||
@ -139,7 +140,7 @@ export class WhmcsInvoiceService {
|
|||||||
|
|
||||||
// Add delay between batches (except for the last batch) to respect rate limits
|
// Add delay between batches (except for the last batch) to respect rate limits
|
||||||
if (i < batches.length - 1) {
|
if (i < batches.length - 1) {
|
||||||
await this.sleep(BATCH_DELAY_MS);
|
await sleep(BATCH_DELAY_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,24 +163,6 @@ export class WhmcsInvoiceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Split an array into chunks of specified size
|
|
||||||
*/
|
|
||||||
private chunkArray<T>(array: T[], size: number): T[][] {
|
|
||||||
const chunks: T[][] = [];
|
|
||||||
for (let i = 0; i < array.length; i += size) {
|
|
||||||
chunks.push(array.slice(i, i + size));
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Promise-based sleep for rate limiting
|
|
||||||
*/
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get individual invoice by ID with caching
|
* Get individual invoice by ID with caching
|
||||||
*/
|
*/
|
||||||
@ -492,35 +475,18 @@ export class WhmcsInvoiceService {
|
|||||||
*/
|
*/
|
||||||
private getUserFriendlyPaymentError(technicalError: string): string {
|
private getUserFriendlyPaymentError(technicalError: string): string {
|
||||||
if (!technicalError) {
|
if (!technicalError) {
|
||||||
return "Unable to process payment. Please try again or contact support.";
|
return getDefaultMessage("payment");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// First check common error patterns (timeout, network, auth, rate limit, permissions)
|
||||||
|
const commonResult = matchCommonError(technicalError, "payment");
|
||||||
|
if (commonResult.matched) {
|
||||||
|
return commonResult.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment-specific error patterns
|
||||||
const errorLower = technicalError.toLowerCase();
|
const errorLower = technicalError.toLowerCase();
|
||||||
|
|
||||||
// WHMCS API permission errors
|
|
||||||
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
|
||||||
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication/authorization errors
|
|
||||||
if (
|
|
||||||
errorLower.includes("unauthorized") ||
|
|
||||||
errorLower.includes("forbidden") ||
|
|
||||||
errorLower.includes("403")
|
|
||||||
) {
|
|
||||||
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network/timeout errors
|
|
||||||
if (
|
|
||||||
errorLower.includes("timeout") ||
|
|
||||||
errorLower.includes("network") ||
|
|
||||||
errorLower.includes("connection")
|
|
||||||
) {
|
|
||||||
return "Payment processing timed out. Please try again.";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payment method errors
|
|
||||||
if (
|
if (
|
||||||
errorLower.includes("payment method") ||
|
errorLower.includes("payment method") ||
|
||||||
errorLower.includes("card") ||
|
errorLower.includes("card") ||
|
||||||
@ -529,12 +495,10 @@ export class WhmcsInvoiceService {
|
|||||||
return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
|
return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic API errors
|
|
||||||
if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) {
|
if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) {
|
||||||
return "Payment processing failed. Please try again or contact support if the issue persists.";
|
return "Payment processing failed. Please try again or contact support if the issue persists.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback
|
return getDefaultMessage("payment");
|
||||||
return "Unable to process payment. Please try again or contact support.";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||||
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
|
import { OrderOrchestrator } from "@bff/modules/orders/services/order-orchestrator.service.js";
|
||||||
import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js";
|
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
|
||||||
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
||||||
|
|||||||
@ -21,10 +21,10 @@ import type {
|
|||||||
SimActivationFeeCatalogItem,
|
SimActivationFeeCatalogItem,
|
||||||
VpnCatalogProduct,
|
VpnCatalogProduct,
|
||||||
} from "@customer-portal/domain/services";
|
} from "@customer-portal/domain/services";
|
||||||
import { InternetServicesService } from "@bff/modules/services/services/internet-services.service.js";
|
import { InternetServicesService } from "@bff/modules/services/application/internet-services.service.js";
|
||||||
import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js";
|
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
|
||||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js";
|
||||||
import { VpnServicesService } from "@bff/modules/services/services/vpn-services.service.js";
|
import { VpnServicesService } from "@bff/modules/services/application/vpn-services.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@ -12,8 +12,8 @@ import {
|
|||||||
import type * as Providers from "@customer-portal/domain/subscriptions/providers";
|
import type * as Providers from "@customer-portal/domain/subscriptions/providers";
|
||||||
|
|
||||||
type WhmcsProduct = Providers.WhmcsProductRaw;
|
type WhmcsProduct = Providers.WhmcsProductRaw;
|
||||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js";
|
||||||
import { InternetEligibilityService } from "@bff/modules/services/services/internet-eligibility.service.js";
|
import { InternetEligibilityService } from "@bff/modules/services/application/internet-eligibility.service.js";
|
||||||
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
|
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service.js";
|
||||||
import { PaymentValidatorService } from "./payment-validator.service.js";
|
import { PaymentValidatorService } from "./payment-validator.service.js";
|
||||||
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
import { ResidenceCardService } from "@bff/modules/verification/residence-card.service.js";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import {
|
import {
|
||||||
@ -16,7 +16,7 @@ export interface SimFulfillmentRequest {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimFulfillmentService {
|
export class SimFulfillmentService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebit: FreebitOrchestratorService,
|
private readonly freebit: FreebitOperationsService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,9 @@ import {
|
|||||||
type SimCatalogCollection,
|
type SimCatalogCollection,
|
||||||
type VpnCatalogCollection,
|
type VpnCatalogCollection,
|
||||||
} from "@customer-portal/domain/services";
|
} from "@customer-portal/domain/services";
|
||||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
import { InternetServicesService } from "./application/internet-services.service.js";
|
||||||
import { SimServicesService } from "./services/sim-services.service.js";
|
import { SimServicesService } from "./application/sim-services.service.js";
|
||||||
import { VpnServicesService } from "./services/vpn-services.service.js";
|
import { VpnServicesService } from "./application/vpn-services.service.js";
|
||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
|
|
||||||
@Controller("account/services")
|
@Controller("account/services")
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Body, Controller, Get, Header, Post, Req, UseGuards } from "@nestjs/com
|
|||||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||||
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
import { RateLimit, RateLimitGuard } from "@bff/core/rate-limiting/index.js";
|
||||||
import { InternetEligibilityService } from "./services/internet-eligibility.service.js";
|
import { InternetEligibilityService } from "./application/internet-eligibility.service.js";
|
||||||
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
|
import type { InternetEligibilityDetails } from "@customer-portal/domain/services";
|
||||||
import {
|
import {
|
||||||
internetEligibilityDetailsSchema,
|
internetEligibilityDetailsSchema,
|
||||||
|
|||||||
@ -9,9 +9,9 @@ import {
|
|||||||
type SimCatalogCollection,
|
type SimCatalogCollection,
|
||||||
type VpnCatalogCollection,
|
type VpnCatalogCollection,
|
||||||
} from "@customer-portal/domain/services";
|
} from "@customer-portal/domain/services";
|
||||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
import { InternetServicesService } from "./application/internet-services.service.js";
|
||||||
import { SimServicesService } from "./services/sim-services.service.js";
|
import { SimServicesService } from "./application/sim-services.service.js";
|
||||||
import { VpnServicesService } from "./services/vpn-services.service.js";
|
import { VpnServicesService } from "./application/vpn-services.service.js";
|
||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
|
|
||||||
@Controller("public/services")
|
@Controller("public/services")
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { ServicesCacheService } from "./services/services-cache.service.js";
|
import { ServicesCacheService } from "./application/services-cache.service.js";
|
||||||
import type { ServicesCacheSnapshot } from "./services/services-cache.service.js";
|
import type { ServicesCacheSnapshot } from "./application/services-cache.service.js";
|
||||||
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
import { Public } from "@bff/modules/auth/decorators/public.decorator.js";
|
||||||
|
|
||||||
interface ServicesCacheHealthResponse {
|
interface ServicesCacheHealthResponse {
|
||||||
|
|||||||
@ -15,9 +15,9 @@ import {
|
|||||||
type VpnCatalogProduct,
|
type VpnCatalogProduct,
|
||||||
type VpnCatalogCollection,
|
type VpnCatalogCollection,
|
||||||
} from "@customer-portal/domain/services";
|
} from "@customer-portal/domain/services";
|
||||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
import { InternetServicesService } from "./application/internet-services.service.js";
|
||||||
import { SimServicesService } from "./services/sim-services.service.js";
|
import { SimServicesService } from "./application/sim-services.service.js";
|
||||||
import { VpnServicesService } from "./services/vpn-services.service.js";
|
import { VpnServicesService } from "./application/vpn-services.service.js";
|
||||||
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
import { SalesforceReadThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-read-throttle.guard.js";
|
||||||
|
|
||||||
@Controller("services")
|
@Controller("services")
|
||||||
|
|||||||
@ -10,12 +10,12 @@ import { CoreConfigModule } from "@bff/core/config/config.module.js";
|
|||||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||||
import { QueueModule } from "@bff/infra/queue/queue.module.js";
|
import { QueueModule } from "@bff/infra/queue/queue.module.js";
|
||||||
|
|
||||||
import { BaseServicesService } from "./services/base-services.service.js";
|
import { BaseServicesService } from "./application/base-services.service.js";
|
||||||
import { InternetServicesService } from "./services/internet-services.service.js";
|
import { InternetServicesService } from "./application/internet-services.service.js";
|
||||||
import { InternetEligibilityService } from "./services/internet-eligibility.service.js";
|
import { InternetEligibilityService } from "./application/internet-eligibility.service.js";
|
||||||
import { SimServicesService } from "./services/sim-services.service.js";
|
import { SimServicesService } from "./application/sim-services.service.js";
|
||||||
import { VpnServicesService } from "./services/vpn-services.service.js";
|
import { VpnServicesService } from "./application/vpn-services.service.js";
|
||||||
import { ServicesCacheService } from "./services/services-cache.service.js";
|
import { ServicesCacheService } from "./application/services-cache.service.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@ -21,10 +21,12 @@ import { SalesforceOpportunityService } from "@bff/integrations/salesforce/servi
|
|||||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||||
import type {
|
import {
|
||||||
InternetCancellationPreview,
|
generateCancellationMonths,
|
||||||
InternetCancellationMonth,
|
getCancellationEffectiveDate,
|
||||||
InternetCancelRequest,
|
type InternetCancellationPreview,
|
||||||
|
type InternetCancellationMonth,
|
||||||
|
type InternetCancelRequest,
|
||||||
} from "@customer-portal/domain/subscriptions";
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import {
|
import {
|
||||||
type InternetCancellationOpportunityData,
|
type InternetCancellationOpportunityData,
|
||||||
@ -46,33 +48,6 @@ export class InternetCancellationService {
|
|||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate available cancellation months (next 12 months)
|
|
||||||
* Following the 25th rule: if before 25th, current month is available
|
|
||||||
*/
|
|
||||||
private generateCancellationMonths(): InternetCancellationMonth[] {
|
|
||||||
const months: InternetCancellationMonth[] = [];
|
|
||||||
const today = new Date();
|
|
||||||
const dayOfMonth = today.getDate();
|
|
||||||
|
|
||||||
// Start from current month if before 25th, otherwise next month
|
|
||||||
const startOffset = dayOfMonth <= 25 ? 0 : 1;
|
|
||||||
|
|
||||||
for (let i = startOffset; i < startOffset + 12; i++) {
|
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth() + 1;
|
|
||||||
const monthStr = String(month).padStart(2, "0");
|
|
||||||
|
|
||||||
months.push({
|
|
||||||
value: `${year}-${monthStr}`,
|
|
||||||
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return months;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that the subscription belongs to the user and is an Internet service
|
* Validate that the subscription belongs to the user and is an Internet service
|
||||||
*/
|
*/
|
||||||
@ -163,7 +138,7 @@ export class InternetCancellationService {
|
|||||||
billingAmount: subscription.amount,
|
billingAmount: subscription.amount,
|
||||||
nextDueDate: subscription.nextDue,
|
nextDueDate: subscription.nextDue,
|
||||||
registrationDate: subscription.registrationDate,
|
registrationDate: subscription.registrationDate,
|
||||||
availableMonths: this.generateCancellationMonths(),
|
availableMonths: generateCancellationMonths() as InternetCancellationMonth[],
|
||||||
customerEmail,
|
customerEmail,
|
||||||
customerName,
|
customerName,
|
||||||
};
|
};
|
||||||
@ -189,21 +164,14 @@ export class InternetCancellationService {
|
|||||||
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cancellation month and calculate end date
|
// Calculate cancellation date (end of selected month)
|
||||||
const [year, month] = request.cancellationMonth.split("-").map(Number);
|
let cancellationDate: string;
|
||||||
if (!year || !month) {
|
try {
|
||||||
|
cancellationDate = getCancellationEffectiveDate(request.cancellationMonth);
|
||||||
|
} catch {
|
||||||
throw new BadRequestException("Invalid cancellation month format");
|
throw new BadRequestException("Invalid cancellation month format");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancellation date is end of selected month
|
|
||||||
const lastDayOfMonth = new Date(year, month, 0);
|
|
||||||
// Use local date components to avoid timezone shifts when converting to string
|
|
||||||
const cancellationDate = [
|
|
||||||
lastDayOfMonth.getFullYear(),
|
|
||||||
String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"),
|
|
||||||
String(lastDayOfMonth.getDate()).padStart(2, "0"),
|
|
||||||
].join("-");
|
|
||||||
|
|
||||||
this.logger.log("Processing Internet cancellation request", {
|
this.logger.log("Processing Internet cancellation request", {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Inject, Injectable } from "@nestjs/common";
|
|||||||
import type { Job } from "bullmq";
|
import type { Job } from "bullmq";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants.js";
|
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants.js";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import {
|
import {
|
||||||
SIM_MANAGEMENT_JOB_NAMES as JOB_NAMES,
|
SIM_MANAGEMENT_JOB_NAMES as JOB_NAMES,
|
||||||
@ -14,7 +14,7 @@ import {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimManagementProcessor extends WorkerHost {
|
export class SimManagementProcessor extends WorkerHost {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@ -1,24 +1,22 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
import { SimNotificationService } from "./sim-notification.service.js";
|
||||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim";
|
import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EsimManagementService {
|
export class EsimManagementService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simNotification: SimNotificationService,
|
private readonly simNotification: SimNotificationService,
|
||||||
private readonly apiNotification: SimApiNotificationService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
@ -131,7 +129,7 @@ export class EsimManagementService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send API results email to admin
|
// Send API results email to admin
|
||||||
await this.apiNotification.sendApiResultsEmail("SIM Re-issue Request", [
|
await this.simNotification.sendApiResultsEmail("SIM Re-issue Request", [
|
||||||
{
|
{
|
||||||
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
|
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
|
||||||
json: {
|
json: {
|
||||||
@ -149,12 +147,12 @@ export class EsimManagementService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Send customer email
|
// Send customer email
|
||||||
const customerEmailBody = this.apiNotification.buildEsimReissueEmail(
|
const customerEmailBody = this.simNotification.buildEsimReissueEmail(
|
||||||
customerName,
|
customerName,
|
||||||
account,
|
account,
|
||||||
request.newEid
|
request.newEid
|
||||||
);
|
);
|
||||||
await this.apiNotification.sendCustomerEmail(
|
await this.simNotification.sendCustomerEmail(
|
||||||
customerEmail,
|
customerEmail,
|
||||||
"SIM Re-issue Request",
|
"SIM Re-issue Request",
|
||||||
customerEmailBody
|
customerEmailBody
|
||||||
@ -176,18 +174,18 @@ export class EsimManagementService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send admin notification email
|
// Send admin notification email
|
||||||
await this.apiNotification.sendApiResultsEmail(
|
await this.simNotification.sendApiResultsEmail(
|
||||||
"Physical SIM Re-issue Request",
|
"Physical SIM Re-issue Request",
|
||||||
[],
|
[],
|
||||||
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
|
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send customer email
|
// Send customer email
|
||||||
const customerEmailBody = this.apiNotification.buildPhysicalSimReissueEmail(
|
const customerEmailBody = this.simNotification.buildPhysicalSimReissueEmail(
|
||||||
customerName,
|
customerName,
|
||||||
account
|
account
|
||||||
);
|
);
|
||||||
await this.apiNotification.sendCustomerEmail(
|
await this.simNotification.sendCustomerEmail(
|
||||||
customerEmail,
|
customerEmail,
|
||||||
"Physical SIM Re-issue Request",
|
"Physical SIM Re-issue Request",
|
||||||
customerEmailBody
|
customerEmailBody
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
import { SimNotificationService } from "./sim-notification.service.js";
|
|
||||||
import type { SimNotificationContext } from "../interfaces/sim-base.interface.js";
|
|
||||||
|
|
||||||
interface RunOptions<T> {
|
|
||||||
baseContext: SimNotificationContext;
|
|
||||||
enrichSuccess?: (result: T) => Partial<SimNotificationContext>;
|
|
||||||
enrichError?: (error: unknown) => Partial<SimNotificationContext>;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimActionRunnerService {
|
|
||||||
constructor(private readonly simNotification: SimNotificationService) {}
|
|
||||||
|
|
||||||
async run<T>(action: string, options: RunOptions<T>, handler: () => Promise<T>): Promise<T> {
|
|
||||||
try {
|
|
||||||
const result = await handler();
|
|
||||||
const successContext = {
|
|
||||||
...options.baseContext,
|
|
||||||
...(options.enrichSuccess ? options.enrichSuccess(result) : {}),
|
|
||||||
};
|
|
||||||
await this.simNotification.notifySimAction(action, "SUCCESS", successContext);
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
const errorContext = {
|
|
||||||
...options.baseContext,
|
|
||||||
error: extractErrorMessage(error),
|
|
||||||
...(options.enrichError ? options.enrichError(error) : {}),
|
|
||||||
};
|
|
||||||
await this.simNotification.notifySimAction(action, "ERROR", errorContext);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
|
||||||
|
|
||||||
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
|
||||||
|
|
||||||
export interface ApiCallLog {
|
|
||||||
url: string;
|
|
||||||
senddata?: Record<string, unknown> | string;
|
|
||||||
json?: Record<string, unknown> | string;
|
|
||||||
result: Record<string, unknown> | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class SimApiNotificationService {
|
|
||||||
constructor(
|
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
|
||||||
private readonly email: EmailService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send API results notification email to admin
|
|
||||||
*/
|
|
||||||
async sendApiResultsEmail(
|
|
||||||
subject: string,
|
|
||||||
apiCalls: ApiCallLog[],
|
|
||||||
additionalInfo?: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
for (const call of apiCalls) {
|
|
||||||
lines.push(`url: ${call.url}`);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
if (call.senddata) {
|
|
||||||
const senddataStr =
|
|
||||||
typeof call.senddata === "string"
|
|
||||||
? call.senddata
|
|
||||||
: JSON.stringify(call.senddata, null, 2);
|
|
||||||
lines.push(`senddata: ${senddataStr}`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (call.json) {
|
|
||||||
const jsonStr =
|
|
||||||
typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2);
|
|
||||||
lines.push(`json: ${jsonStr}`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultStr =
|
|
||||||
typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2);
|
|
||||||
lines.push(`result: ${resultStr}`);
|
|
||||||
lines.push("");
|
|
||||||
lines.push("---");
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalInfo) {
|
|
||||||
lines.push(additionalInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.email.sendEmail({
|
|
||||||
to: ADMIN_EMAIL,
|
|
||||||
from: ADMIN_EMAIL,
|
|
||||||
subject,
|
|
||||||
text: lines.join("\n"),
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log("Sent API results notification email", {
|
|
||||||
subject,
|
|
||||||
to: ADMIN_EMAIL,
|
|
||||||
callCount: apiCalls.length,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn("Failed to send API results notification email", {
|
|
||||||
subject,
|
|
||||||
error: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send customer notification email
|
|
||||||
*/
|
|
||||||
async sendCustomerEmail(to: string, subject: string, body: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.email.sendEmail({
|
|
||||||
to,
|
|
||||||
from: ADMIN_EMAIL,
|
|
||||||
subject,
|
|
||||||
text: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log("Sent customer notification email", {
|
|
||||||
subject,
|
|
||||||
to,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn("Failed to send customer notification email", {
|
|
||||||
subject,
|
|
||||||
to,
|
|
||||||
error: extractErrorMessage(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build eSIM reissue customer email body
|
|
||||||
*/
|
|
||||||
buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string {
|
|
||||||
return `Dear ${customerName},
|
|
||||||
|
|
||||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
|
||||||
to the EID=${newEid} has been accepted.
|
|
||||||
|
|
||||||
Please download the SIM plan, then follow the instructions to install the APN profile.
|
|
||||||
|
|
||||||
eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf
|
|
||||||
APN profile instructions: https://www.asolutions.co.jp/sim-card/
|
|
||||||
|
|
||||||
With best regards,
|
|
||||||
Assist Solutions Customer Support
|
|
||||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
|
||||||
Email: ${ADMIN_EMAIL}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build physical SIM reissue customer email body
|
|
||||||
*/
|
|
||||||
buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string {
|
|
||||||
return `Dear ${customerName},
|
|
||||||
|
|
||||||
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
|
||||||
as a physical SIM has been accepted.
|
|
||||||
|
|
||||||
You will be contacted by us again as soon as details about the shipping
|
|
||||||
schedule can be disclosed (typically in 3-5 business days).
|
|
||||||
|
|
||||||
With best regards,
|
|
||||||
Assist Solutions Customer Support
|
|
||||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
|
||||||
Email: ${ADMIN_EMAIL}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build cancellation notification email body for admin
|
|
||||||
*/
|
|
||||||
buildCancellationAdminEmail(params: {
|
|
||||||
customerName: string;
|
|
||||||
simNumber: string;
|
|
||||||
serialNumber?: string;
|
|
||||||
cancellationMonth: string;
|
|
||||||
registeredEmail: string;
|
|
||||||
comments?: string;
|
|
||||||
}): string {
|
|
||||||
return `The following SONIXNET SIM cancellation has been requested.
|
|
||||||
|
|
||||||
Customer name: ${params.customerName}
|
|
||||||
SIM #: ${params.simNumber}
|
|
||||||
Serial #: ${params.serialNumber || "N/A"}
|
|
||||||
Cancellation month: ${params.cancellationMonth}
|
|
||||||
Registered email address: ${params.registeredEmail}
|
|
||||||
Comments: ${params.comments || "N/A"}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for formatting call history data for display.
|
||||||
|
* Handles time, duration, and phone number formatting.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimCallHistoryFormatterService {
|
||||||
|
/**
|
||||||
|
* Convert HHMMSS to HH:MM:SS display format
|
||||||
|
*/
|
||||||
|
formatTime(timeStr: string): string {
|
||||||
|
if (!timeStr || timeStr.length < 6) return timeStr;
|
||||||
|
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
||||||
|
return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert seconds to readable duration format (Xh Ym Zs)
|
||||||
|
*/
|
||||||
|
formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${secs}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Japanese phone numbers with appropriate separators
|
||||||
|
*/
|
||||||
|
formatPhoneNumber(phone: string): string {
|
||||||
|
if (!phone) return phone;
|
||||||
|
const clean = phone.replace(/[^0-9+]/g, "");
|
||||||
|
|
||||||
|
// 080-XXXX-XXXX or 070-XXXX-XXXX or 090-XXXX-XXXX format
|
||||||
|
if (
|
||||||
|
clean.length === 11 &&
|
||||||
|
(clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))
|
||||||
|
) {
|
||||||
|
return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 03-XXXX-XXXX format (landline)
|
||||||
|
if (clean.length === 10 && clean.startsWith("0")) {
|
||||||
|
return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default month for call history queries (2 months ago)
|
||||||
|
*/
|
||||||
|
getDefaultMonth(): string {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMonth(now.getMonth() - 2);
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date to YYYY-MM format
|
||||||
|
*/
|
||||||
|
formatYearMonth(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert YYYYMM to YYYY-MM format
|
||||||
|
*/
|
||||||
|
normalizeMonth(yearMonth: string): string {
|
||||||
|
return `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,295 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
// SmsType enum to match Prisma schema
|
||||||
|
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||||
|
|
||||||
|
export interface DomesticCallRecord {
|
||||||
|
account: string;
|
||||||
|
callDate: Date;
|
||||||
|
callTime: string;
|
||||||
|
calledTo: string;
|
||||||
|
location: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
chargeYen: number;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternationalCallRecord {
|
||||||
|
account: string;
|
||||||
|
callDate: Date;
|
||||||
|
startTime: string;
|
||||||
|
stopTime: string | null;
|
||||||
|
country: string | null;
|
||||||
|
calledTo: string;
|
||||||
|
durationSec: number;
|
||||||
|
chargeYen: number;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsRecord {
|
||||||
|
account: string;
|
||||||
|
smsDate: Date;
|
||||||
|
smsTime: string;
|
||||||
|
sentTo: string;
|
||||||
|
smsType: SmsType;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTalkDetail {
|
||||||
|
domestic: DomesticCallRecord[];
|
||||||
|
international: InternationalCallRecord[];
|
||||||
|
skipped: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSmsDetail {
|
||||||
|
records: SmsRecord[];
|
||||||
|
skipped: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for parsing call history CSV files from Freebit SFTP.
|
||||||
|
* Handles the specific CSV formats for talk detail and SMS detail files.
|
||||||
|
*
|
||||||
|
* Talk Detail CSV Columns:
|
||||||
|
* 1. Customer phone number
|
||||||
|
* 2. Date (YYYYMMDD)
|
||||||
|
* 3. Start time (HHMMSS)
|
||||||
|
* 4. Called to phone number
|
||||||
|
* 5. dome/tointl (call type)
|
||||||
|
* 6. Location
|
||||||
|
* 7. Duration (MMSST format - minutes, seconds, tenths)
|
||||||
|
* 8. Tokens (each token = 10 yen)
|
||||||
|
* 9. Alternative charge (if location is "他社")
|
||||||
|
*
|
||||||
|
* SMS Detail CSV Columns:
|
||||||
|
* 1. Customer phone number
|
||||||
|
* 2. Date (YYYYMMDD)
|
||||||
|
* 3. Start time (HHMMSS)
|
||||||
|
* 4. SMS sent to phone number
|
||||||
|
* 5. dome/tointl
|
||||||
|
* 6. SMS type (SMS or 国際SMS)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimCallHistoryParserService {
|
||||||
|
/**
|
||||||
|
* Parse talk detail CSV content into domestic and international call records
|
||||||
|
*/
|
||||||
|
parseTalkDetailCsv(content: string, month: string): ParsedTalkDetail {
|
||||||
|
const domestic: DomesticCallRecord[] = [];
|
||||||
|
const international: InternationalCallRecord[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const columns = this.parseCsvLine(line);
|
||||||
|
|
||||||
|
if (columns.length < 8) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
phoneNumber,
|
||||||
|
dateStr,
|
||||||
|
timeStr,
|
||||||
|
calledTo,
|
||||||
|
callType,
|
||||||
|
location,
|
||||||
|
durationStr,
|
||||||
|
tokensStr,
|
||||||
|
altChargeStr,
|
||||||
|
] = columns;
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
const callDate = this.parseDate(dateStr);
|
||||||
|
if (!callDate) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration - format is MMSST (minutes, seconds, tenths)
|
||||||
|
// e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec
|
||||||
|
const durationSec = this.parseDuration(durationStr);
|
||||||
|
|
||||||
|
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
||||||
|
const chargeYen = this.parseCharge(location, tokensStr, altChargeStr);
|
||||||
|
|
||||||
|
// Clean account number (remove dashes, spaces)
|
||||||
|
const account = this.cleanPhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
// Clean called-to number
|
||||||
|
const cleanCalledTo = this.cleanPhoneNumber(calledTo);
|
||||||
|
|
||||||
|
if (callType === "dome" || callType === "domestic") {
|
||||||
|
domestic.push({
|
||||||
|
account,
|
||||||
|
callDate,
|
||||||
|
callTime: timeStr,
|
||||||
|
calledTo: cleanCalledTo,
|
||||||
|
location: location || null,
|
||||||
|
durationSec,
|
||||||
|
chargeYen,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} else if (callType === "tointl" || callType === "international") {
|
||||||
|
international.push({
|
||||||
|
account,
|
||||||
|
callDate,
|
||||||
|
startTime: timeStr,
|
||||||
|
stopTime: null,
|
||||||
|
country: location || null,
|
||||||
|
calledTo: cleanCalledTo,
|
||||||
|
durationSec,
|
||||||
|
chargeYen,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { domestic, international, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SMS detail CSV content
|
||||||
|
*/
|
||||||
|
parseSmsDetailCsv(content: string, month: string): ParsedSmsDetail {
|
||||||
|
const records: SmsRecord[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const columns = this.parseCsvLine(line);
|
||||||
|
|
||||||
|
if (columns.length < 6) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns;
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
const smsDate = this.parseDate(dateStr);
|
||||||
|
if (!smsDate) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean account number
|
||||||
|
const account = this.cleanPhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
// Clean sent-to number
|
||||||
|
const cleanSentTo = this.cleanPhoneNumber(sentTo);
|
||||||
|
|
||||||
|
// Determine SMS type
|
||||||
|
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
||||||
|
|
||||||
|
records.push({
|
||||||
|
account,
|
||||||
|
smsDate,
|
||||||
|
smsTime: timeStr,
|
||||||
|
sentTo: cleanSentTo,
|
||||||
|
smsType,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { records, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a CSV line handling quoted fields and escaped quotes
|
||||||
|
*/
|
||||||
|
private parseCsvLine(line: string): string[] {
|
||||||
|
const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, "");
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedLine.length; i++) {
|
||||||
|
const char = normalizedLine[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && normalizedLine[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "," && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YYYYMMDD date string to Date object
|
||||||
|
*/
|
||||||
|
private parseDate(dateStr: string): Date | null {
|
||||||
|
if (!dateStr || dateStr.length < 8) return null;
|
||||||
|
|
||||||
|
const clean = dateStr.replace(/[^0-9]/g, "");
|
||||||
|
if (clean.length < 8) return null;
|
||||||
|
|
||||||
|
const year = parseInt(clean.substring(0, 4), 10);
|
||||||
|
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
||||||
|
const day = parseInt(clean.substring(6, 8), 10);
|
||||||
|
|
||||||
|
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
||||||
|
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse duration string (MMSST format) to seconds
|
||||||
|
*/
|
||||||
|
private parseDuration(durationStr: string): number {
|
||||||
|
const durationVal = durationStr.padStart(5, "0");
|
||||||
|
const minutes = parseInt(durationVal.slice(0, -3), 10) || 0;
|
||||||
|
const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse charge from tokens or alternative charge
|
||||||
|
*/
|
||||||
|
private parseCharge(
|
||||||
|
location: string | undefined,
|
||||||
|
tokensStr: string,
|
||||||
|
altChargeStr: string | undefined
|
||||||
|
): number {
|
||||||
|
if (location && location.includes("他社") && altChargeStr) {
|
||||||
|
return parseInt(altChargeStr, 10) || 0;
|
||||||
|
}
|
||||||
|
return (parseInt(tokensStr, 10) || 0) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean phone number by removing dashes and spaces
|
||||||
|
*/
|
||||||
|
private cleanPhoneNumber(phone: string): string {
|
||||||
|
return phone.replace(/[-\s]/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,47 +3,24 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||||
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js";
|
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
|
import { SimCallHistoryParserService } from "./sim-call-history-parser.service.js";
|
||||||
|
import { SimCallHistoryFormatterService } from "./sim-call-history-formatter.service.js";
|
||||||
import type {
|
import type {
|
||||||
SimDomesticCallHistoryResponse,
|
SimDomesticCallHistoryResponse,
|
||||||
SimInternationalCallHistoryResponse,
|
SimInternationalCallHistoryResponse,
|
||||||
SimSmsHistoryResponse,
|
SimSmsHistoryResponse,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
|
// Re-export types for consumers
|
||||||
|
export type {
|
||||||
|
DomesticCallRecord,
|
||||||
|
InternationalCallRecord,
|
||||||
|
SmsRecord,
|
||||||
|
} from "./sim-call-history-parser.service.js";
|
||||||
|
|
||||||
// SmsType enum to match Prisma schema
|
// SmsType enum to match Prisma schema
|
||||||
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||||
|
|
||||||
export interface DomesticCallRecord {
|
|
||||||
account: string;
|
|
||||||
callDate: Date;
|
|
||||||
callTime: string;
|
|
||||||
calledTo: string;
|
|
||||||
location: string | null;
|
|
||||||
durationSec: number;
|
|
||||||
chargeYen: number;
|
|
||||||
month: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InternationalCallRecord {
|
|
||||||
account: string;
|
|
||||||
callDate: Date;
|
|
||||||
startTime: string;
|
|
||||||
stopTime: string | null;
|
|
||||||
country: string | null;
|
|
||||||
calledTo: string;
|
|
||||||
durationSec: number;
|
|
||||||
chargeYen: number;
|
|
||||||
month: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SmsRecord {
|
|
||||||
account: string;
|
|
||||||
smsDate: Date;
|
|
||||||
smsTime: string;
|
|
||||||
sentTo: string;
|
|
||||||
smsType: SmsType;
|
|
||||||
month: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CallHistoryPagination {
|
export interface CallHistoryPagination {
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@ -51,180 +28,21 @@ export interface CallHistoryPagination {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing SIM call history data.
|
||||||
|
* Coordinates importing data from SFTP and querying stored history.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimCallHistoryService {
|
export class SimCallHistoryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sftp: SftpClientService,
|
private readonly sftp: SftpClientService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly parser: SimCallHistoryParserService,
|
||||||
|
private readonly formatter: SimCallHistoryFormatterService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse talk detail CSV content
|
|
||||||
* Columns:
|
|
||||||
* 1. Customer phone number
|
|
||||||
* 2. Date (YYYYMMDD)
|
|
||||||
* 3. Start time (HHMMSS)
|
|
||||||
* 4. Called to phone number
|
|
||||||
* 5. dome/tointl
|
|
||||||
* 6. Location
|
|
||||||
* 7. Duration (320 = 32.0 seconds)
|
|
||||||
* 8. Tokens (each token = 10 yen)
|
|
||||||
* 9. Alternative charge (if column 6 says "他社")
|
|
||||||
*/
|
|
||||||
parseTalkDetailCsv(
|
|
||||||
content: string,
|
|
||||||
month: string
|
|
||||||
): { domestic: DomesticCallRecord[]; international: InternationalCallRecord[] } {
|
|
||||||
const domestic: DomesticCallRecord[] = [];
|
|
||||||
const international: InternationalCallRecord[] = [];
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
try {
|
|
||||||
// Parse CSV line - handle potential commas in values
|
|
||||||
const columns = this.parseCsvLine(line);
|
|
||||||
|
|
||||||
if (columns.length < 8) {
|
|
||||||
this.logger.debug(`Skipping line ${i + 1}: insufficient columns`, { line });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [
|
|
||||||
phoneNumber,
|
|
||||||
dateStr,
|
|
||||||
timeStr,
|
|
||||||
calledTo,
|
|
||||||
callType,
|
|
||||||
location,
|
|
||||||
durationStr,
|
|
||||||
tokensStr,
|
|
||||||
altChargeStr,
|
|
||||||
] = columns;
|
|
||||||
|
|
||||||
// Parse date
|
|
||||||
const callDate = this.parseDate(dateStr);
|
|
||||||
if (!callDate) {
|
|
||||||
this.logger.debug(`Skipping line ${i + 1}: invalid date`, { dateStr });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse duration - format is MMSST (minutes, seconds, tenths)
|
|
||||||
// e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec
|
|
||||||
const durationVal = durationStr.padStart(5, "0"); // Ensure at least 5 digits
|
|
||||||
const minutes = parseInt(durationVal.slice(0, -3), 10) || 0; // All but last 3 digits
|
|
||||||
const seconds = parseInt(durationVal.slice(-3, -1), 10) || 0; // 2 digits before last
|
|
||||||
// Last digit is tenths, which we ignore
|
|
||||||
const durationSec = minutes * 60 + seconds;
|
|
||||||
|
|
||||||
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
|
||||||
let chargeYen: number;
|
|
||||||
if (location && location.includes("他社") && altChargeStr) {
|
|
||||||
chargeYen = parseInt(altChargeStr, 10) || 0;
|
|
||||||
} else {
|
|
||||||
chargeYen = (parseInt(tokensStr, 10) || 0) * 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean account number (remove dashes, spaces)
|
|
||||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
|
||||||
|
|
||||||
// Clean called-to number
|
|
||||||
const cleanCalledTo = calledTo.replace(/[-\s]/g, "");
|
|
||||||
|
|
||||||
if (callType === "dome" || callType === "domestic") {
|
|
||||||
domestic.push({
|
|
||||||
account,
|
|
||||||
callDate,
|
|
||||||
callTime: timeStr,
|
|
||||||
calledTo: cleanCalledTo,
|
|
||||||
location: location || null,
|
|
||||||
durationSec,
|
|
||||||
chargeYen,
|
|
||||||
month,
|
|
||||||
});
|
|
||||||
} else if (callType === "tointl" || callType === "international") {
|
|
||||||
international.push({
|
|
||||||
account,
|
|
||||||
callDate,
|
|
||||||
startTime: timeStr,
|
|
||||||
stopTime: null, // Could be calculated from duration if needed
|
|
||||||
country: location || null,
|
|
||||||
calledTo: cleanCalledTo,
|
|
||||||
durationSec,
|
|
||||||
chargeYen,
|
|
||||||
month,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to parse talk detail line ${i + 1}`, { line, error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { domestic, international };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse SMS detail CSV content
|
|
||||||
* Columns:
|
|
||||||
* 1. Customer phone number
|
|
||||||
* 2. Date (YYYYMMDD)
|
|
||||||
* 3. Start time (HHMMSS)
|
|
||||||
* 4. SMS sent to phone number
|
|
||||||
* 5. dome/tointl
|
|
||||||
* 6. SMS type (SMS or 国際SMS)
|
|
||||||
*/
|
|
||||||
parseSmsDetailCsv(content: string, month: string): SmsRecord[] {
|
|
||||||
const records: SmsRecord[] = [];
|
|
||||||
|
|
||||||
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const line = lines[i];
|
|
||||||
try {
|
|
||||||
const columns = this.parseCsvLine(line);
|
|
||||||
|
|
||||||
if (columns.length < 6) {
|
|
||||||
this.logger.debug(`Skipping SMS line ${i + 1}: insufficient columns`, { line });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [phoneNumber, dateStr, timeStr, sentTo, , smsTypeStr] = columns;
|
|
||||||
|
|
||||||
// Parse date
|
|
||||||
const smsDate = this.parseDate(dateStr);
|
|
||||||
if (!smsDate) {
|
|
||||||
this.logger.debug(`Skipping SMS line ${i + 1}: invalid date`, { dateStr });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean account number
|
|
||||||
const account = phoneNumber.replace(/[-\s]/g, "");
|
|
||||||
|
|
||||||
// Clean sent-to number
|
|
||||||
const cleanSentTo = sentTo.replace(/[-\s]/g, "");
|
|
||||||
|
|
||||||
// Determine SMS type
|
|
||||||
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
|
||||||
|
|
||||||
records.push({
|
|
||||||
account,
|
|
||||||
smsDate,
|
|
||||||
smsTime: timeStr,
|
|
||||||
sentTo: cleanSentTo,
|
|
||||||
smsType,
|
|
||||||
month,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to parse SMS detail line ${i + 1}`, { line, error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import call history from SFTP for a specific month
|
* Import call history from SFTP for a specific month
|
||||||
*/
|
*/
|
||||||
@ -233,7 +51,7 @@ export class SimCallHistoryService {
|
|||||||
international: number;
|
international: number;
|
||||||
sms: number;
|
sms: number;
|
||||||
}> {
|
}> {
|
||||||
const month = `${yearMonth.substring(0, 4)}-${yearMonth.substring(4, 6)}`;
|
const month = this.formatter.normalizeMonth(yearMonth);
|
||||||
|
|
||||||
this.logger.log(`Starting call history import for ${month}`);
|
this.logger.log(`Starting call history import for ${month}`);
|
||||||
|
|
||||||
@ -247,13 +65,20 @@ export class SimCallHistoryService {
|
|||||||
let internationalCount = 0;
|
let internationalCount = 0;
|
||||||
let smsCount = 0;
|
let smsCount = 0;
|
||||||
|
|
||||||
|
// Import talk detail (calls)
|
||||||
try {
|
try {
|
||||||
// Download and parse talk detail
|
|
||||||
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
||||||
const { domestic, international } = this.parseTalkDetailCsv(talkContent, month);
|
const parsed = this.parser.parseTalkDetailCsv(talkContent, month);
|
||||||
|
|
||||||
|
this.logger.log(`Parsed talk detail`, {
|
||||||
|
domestic: parsed.domestic.length,
|
||||||
|
international: parsed.international.length,
|
||||||
|
skipped: parsed.skipped,
|
||||||
|
errors: parsed.errors,
|
||||||
|
});
|
||||||
|
|
||||||
domesticCount = await this.processInBatches(
|
domesticCount = await this.processInBatches(
|
||||||
domestic,
|
parsed.domestic,
|
||||||
50,
|
50,
|
||||||
record =>
|
record =>
|
||||||
this.prisma.simCallHistoryDomestic.upsert({
|
this.prisma.simCallHistoryDomestic.upsert({
|
||||||
@ -277,7 +102,7 @@ export class SimCallHistoryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
internationalCount = await this.processInBatches(
|
internationalCount = await this.processInBatches(
|
||||||
international,
|
parsed.international,
|
||||||
50,
|
50,
|
||||||
record =>
|
record =>
|
||||||
this.prisma.simCallHistoryInternational.upsert({
|
this.prisma.simCallHistoryInternational.upsert({
|
||||||
@ -308,13 +133,19 @@ export class SimCallHistoryService {
|
|||||||
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
|
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import SMS detail
|
||||||
try {
|
try {
|
||||||
// Download and parse SMS detail
|
|
||||||
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
||||||
const smsRecords = this.parseSmsDetailCsv(smsContent, month);
|
const parsed = this.parser.parseSmsDetailCsv(smsContent, month);
|
||||||
|
|
||||||
|
this.logger.log(`Parsed SMS detail`, {
|
||||||
|
records: parsed.records.length,
|
||||||
|
skipped: parsed.skipped,
|
||||||
|
errors: parsed.errors,
|
||||||
|
});
|
||||||
|
|
||||||
smsCount = await this.processInBatches(
|
smsCount = await this.processInBatches(
|
||||||
smsRecords,
|
parsed.records,
|
||||||
50,
|
50,
|
||||||
record =>
|
record =>
|
||||||
this.prisma.simSmsHistory.upsert({
|
this.prisma.simSmsHistory.upsert({
|
||||||
@ -373,30 +204,22 @@ export class SimCallHistoryService {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50
|
limit: number = 50
|
||||||
): Promise<SimDomesticCallHistoryResponse> {
|
): Promise<SimDomesticCallHistoryResponse> {
|
||||||
// Validate subscription ownership
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
||||||
// TODO: Replace with the validated subscription account once call history data is available per user.
|
// TODO: Replace with the validated subscription account once call history data is available per user.
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
const targetMonth = month || this.getDefaultMonth();
|
|
||||||
|
|
||||||
const [calls, total] = await Promise.all([
|
const [calls, total] = await Promise.all([
|
||||||
this.prisma.simCallHistoryDomestic.findMany({
|
this.prisma.simCallHistoryDomestic.findMany({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
|
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
this.prisma.simCallHistoryDomestic.count({
|
this.prisma.simCallHistoryDomestic.count({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -412,9 +235,9 @@ export class SimCallHistoryService {
|
|||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
date: call.callDate.toISOString().split("T")[0],
|
date: call.callDate.toISOString().split("T")[0],
|
||||||
time: this.formatTime(call.callTime),
|
time: this.formatter.formatTime(call.callTime),
|
||||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
calledTo: this.formatter.formatPhoneNumber(call.calledTo),
|
||||||
callLength: this.formatDuration(call.durationSec),
|
callLength: this.formatter.formatDuration(call.durationSec),
|
||||||
callCharge: call.chargeYen,
|
callCharge: call.chargeYen,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@ -438,30 +261,20 @@ export class SimCallHistoryService {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50
|
limit: number = 50
|
||||||
): Promise<SimInternationalCallHistoryResponse> {
|
): Promise<SimInternationalCallHistoryResponse> {
|
||||||
// Validate subscription ownership
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
|
||||||
// TODO: Replace with the validated subscription account once call history data is available per user.
|
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
const targetMonth = month || this.getDefaultMonth();
|
|
||||||
|
|
||||||
const [calls, total] = await Promise.all([
|
const [calls, total] = await Promise.all([
|
||||||
this.prisma.simCallHistoryInternational.findMany({
|
this.prisma.simCallHistoryInternational.findMany({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
|
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
this.prisma.simCallHistoryInternational.count({
|
this.prisma.simCallHistoryInternational.count({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -478,10 +291,10 @@ export class SimCallHistoryService {
|
|||||||
}) => ({
|
}) => ({
|
||||||
id: call.id,
|
id: call.id,
|
||||||
date: call.callDate.toISOString().split("T")[0],
|
date: call.callDate.toISOString().split("T")[0],
|
||||||
startTime: this.formatTime(call.startTime),
|
startTime: this.formatter.formatTime(call.startTime),
|
||||||
stopTime: call.stopTime ? this.formatTime(call.stopTime) : null,
|
stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null,
|
||||||
country: call.country,
|
country: call.country,
|
||||||
calledTo: this.formatPhoneNumber(call.calledTo),
|
calledTo: this.formatter.formatPhoneNumber(call.calledTo),
|
||||||
callCharge: call.chargeYen,
|
callCharge: call.chargeYen,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@ -505,30 +318,20 @@ export class SimCallHistoryService {
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 50
|
limit: number = 50
|
||||||
): Promise<SimSmsHistoryResponse> {
|
): Promise<SimSmsHistoryResponse> {
|
||||||
// Validate subscription ownership
|
|
||||||
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
|
||||||
// TODO: Replace with the validated subscription account once call history data is available per user.
|
|
||||||
const account = "08077052946";
|
const account = "08077052946";
|
||||||
|
|
||||||
// Default to available month if not specified
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
const targetMonth = month || this.getDefaultMonth();
|
|
||||||
|
|
||||||
const [messages, total] = await Promise.all([
|
const [messages, total] = await Promise.all([
|
||||||
this.prisma.simSmsHistory.findMany({
|
this.prisma.simSmsHistory.findMany({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
|
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
}),
|
}),
|
||||||
this.prisma.simSmsHistory.count({
|
this.prisma.simSmsHistory.count({
|
||||||
where: {
|
where: { account, month: targetMonth },
|
||||||
account,
|
|
||||||
month: targetMonth,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -543,8 +346,8 @@ export class SimCallHistoryService {
|
|||||||
}) => ({
|
}) => ({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
date: msg.smsDate.toISOString().split("T")[0],
|
date: msg.smsDate.toISOString().split("T")[0],
|
||||||
time: this.formatTime(msg.smsTime),
|
time: this.formatter.formatTime(msg.smsTime),
|
||||||
sentTo: this.formatPhoneNumber(msg.sentTo),
|
sentTo: this.formatter.formatPhoneNumber(msg.sentTo),
|
||||||
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
|
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
@ -584,38 +387,9 @@ export class SimCallHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
/**
|
||||||
|
* Process records in batches with error handling
|
||||||
private parseCsvLine(line: string): string[] {
|
*/
|
||||||
const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, "");
|
|
||||||
// CSV parsing with quoted field and escaped quote support
|
|
||||||
const result: string[] = [];
|
|
||||||
let current = "";
|
|
||||||
let inQuotes = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < normalizedLine.length; i++) {
|
|
||||||
const char = normalizedLine[i];
|
|
||||||
if (char === '"') {
|
|
||||||
if (inQuotes && normalizedLine[i + 1] === '"') {
|
|
||||||
current += '"';
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === "," && !inQuotes) {
|
|
||||||
result.push(current.trim());
|
|
||||||
current = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
current += char;
|
|
||||||
}
|
|
||||||
result.push(current.trim());
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processInBatches<T>(
|
private async processInBatches<T>(
|
||||||
records: T[],
|
records: T[],
|
||||||
batchSize: number,
|
batchSize: number,
|
||||||
@ -639,71 +413,4 @@ export class SimCallHistoryService {
|
|||||||
|
|
||||||
return successCount;
|
return successCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseDate(dateStr: string): Date | null {
|
|
||||||
if (!dateStr || dateStr.length < 8) return null;
|
|
||||||
|
|
||||||
// Clean the string
|
|
||||||
const clean = dateStr.replace(/[^0-9]/g, "");
|
|
||||||
if (clean.length < 8) return null;
|
|
||||||
|
|
||||||
const year = parseInt(clean.substring(0, 4), 10);
|
|
||||||
const month = parseInt(clean.substring(4, 6), 10) - 1;
|
|
||||||
const day = parseInt(clean.substring(6, 8), 10);
|
|
||||||
|
|
||||||
if (isNaN(year) || isNaN(month) || isNaN(day)) return null;
|
|
||||||
|
|
||||||
return new Date(year, month, day);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatTime(timeStr: string): string {
|
|
||||||
// Convert HHMMSS to HH:MM:SS
|
|
||||||
if (!timeStr || timeStr.length < 6) return timeStr;
|
|
||||||
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
|
||||||
return `${clean.substring(0, 2)}:${clean.substring(2, 4)}:${clean.substring(4, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDuration(seconds: number): string {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ${secs}s`;
|
|
||||||
} else if (minutes > 0) {
|
|
||||||
return `${minutes}m ${secs}s`;
|
|
||||||
} else {
|
|
||||||
return `${secs}s`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatPhoneNumber(phone: string): string {
|
|
||||||
// Format Japanese phone numbers
|
|
||||||
if (!phone) return phone;
|
|
||||||
const clean = phone.replace(/[^0-9+]/g, "");
|
|
||||||
|
|
||||||
// 080-XXXX-XXXX or 070-XXXX-XXXX format
|
|
||||||
if (
|
|
||||||
clean.length === 11 &&
|
|
||||||
(clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))
|
|
||||||
) {
|
|
||||||
return `${clean.substring(0, 3)}-${clean.substring(3, 7)}-${clean.substring(7)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 03-XXXX-XXXX format
|
|
||||||
if (clean.length === 10 && clean.startsWith("0")) {
|
|
||||||
return `${clean.substring(0, 2)}-${clean.substring(2, 6)}-${clean.substring(6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultMonth(): string {
|
|
||||||
// Default to 2 months ago (available data)
|
|
||||||
const now = new Date();
|
|
||||||
now.setMonth(now.getMonth() - 2);
|
|
||||||
const year = now.getFullYear();
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
return `${year}-${month}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
@ -13,26 +13,29 @@ import type {
|
|||||||
SimCancellationMonth,
|
SimCancellationMonth,
|
||||||
SimCancellationPreview,
|
SimCancellationPreview,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
|
import {
|
||||||
|
generateCancellationMonths,
|
||||||
|
getCancellationEffectiveDate,
|
||||||
|
getRunDateFromMonth,
|
||||||
|
} from "@customer-portal/domain/subscriptions";
|
||||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
|
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
|
||||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
import { SimScheduleService } from "./sim-schedule.service.js";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
import { SimNotificationService } from "./sim-notification.service.js";
|
||||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
|
||||||
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||||
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimCancellationService {
|
export class SimCancellationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly whmcsClientService: WhmcsClientService,
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly opportunityService: SalesforceOpportunityService,
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
private readonly caseService: SalesforceCaseService,
|
private readonly caseService: SalesforceCaseService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
private readonly simNotification: SimNotificationService,
|
||||||
private readonly apiNotification: SimApiNotificationService,
|
|
||||||
private readonly notifications: NotificationService,
|
private readonly notifications: NotificationService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
@ -42,38 +45,6 @@ export class SimCancellationService {
|
|||||||
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate available cancellation months (next 12 months)
|
|
||||||
*/
|
|
||||||
private generateCancellationMonths(): SimCancellationMonth[] {
|
|
||||||
const months: SimCancellationMonth[] = [];
|
|
||||||
const today = new Date();
|
|
||||||
const dayOfMonth = today.getDate();
|
|
||||||
|
|
||||||
// Start from current month if before 25th, otherwise next month
|
|
||||||
const startOffset = dayOfMonth <= 25 ? 0 : 1;
|
|
||||||
|
|
||||||
for (let i = startOffset; i < startOffset + 12; i++) {
|
|
||||||
const date = new Date(today.getFullYear(), today.getMonth() + i, 1);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth() + 1;
|
|
||||||
const monthStr = String(month).padStart(2, "0");
|
|
||||||
|
|
||||||
// runDate is the 1st of the NEXT month (cancellation takes effect at month end)
|
|
||||||
const nextMonth = new Date(year, month, 1);
|
|
||||||
const runYear = nextMonth.getFullYear();
|
|
||||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
|
||||||
|
|
||||||
months.push({
|
|
||||||
value: `${year}-${monthStr}`,
|
|
||||||
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
|
||||||
runDate: `${runYear}${runMonth}01`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return months;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate minimum contract end date (3 months after start, signup month not included)
|
* Calculate minimum contract end date (3 months after start, signup month not included)
|
||||||
*/
|
*/
|
||||||
@ -133,7 +104,9 @@ export class SimCancellationService {
|
|||||||
startDate,
|
startDate,
|
||||||
minimumContractEndDate,
|
minimumContractEndDate,
|
||||||
isWithinMinimumTerm,
|
isWithinMinimumTerm,
|
||||||
availableMonths: this.generateCancellationMonths(),
|
availableMonths: generateCancellationMonths({
|
||||||
|
includeRunDate: true,
|
||||||
|
}) as SimCancellationMonth[],
|
||||||
customerEmail,
|
customerEmail,
|
||||||
customerName,
|
customerName,
|
||||||
};
|
};
|
||||||
@ -149,7 +122,7 @@ export class SimCancellationService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let account = "";
|
let account = "";
|
||||||
|
|
||||||
await this.simActionRunner.run(
|
await this.simNotification.runWithNotification(
|
||||||
"Cancel SIM",
|
"Cancel SIM",
|
||||||
{
|
{
|
||||||
baseContext: {
|
baseContext: {
|
||||||
@ -223,26 +196,16 @@ export class SimCancellationService {
|
|||||||
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse cancellation month and calculate runDate
|
// Calculate runDate and cancellation date using shared utilities
|
||||||
const [year, month] = request.cancellationMonth.split("-").map(Number);
|
let runDate: string;
|
||||||
if (!year || !month) {
|
let cancellationDate: string;
|
||||||
|
try {
|
||||||
|
runDate = getRunDateFromMonth(request.cancellationMonth);
|
||||||
|
cancellationDate = getCancellationEffectiveDate(request.cancellationMonth);
|
||||||
|
} catch {
|
||||||
throw new BadRequestException("Invalid cancellation month format");
|
throw new BadRequestException("Invalid cancellation month format");
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDate is 1st of the NEXT month (cancellation at end of selected month)
|
|
||||||
const nextMonth = new Date(year, month, 1);
|
|
||||||
const runYear = nextMonth.getFullYear();
|
|
||||||
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
|
||||||
const runDate = `${runYear}${runMonth}01`;
|
|
||||||
|
|
||||||
// Calculate the cancellation date (last day of selected month)
|
|
||||||
const lastDayOfMonth = new Date(year, month, 0);
|
|
||||||
const cancellationDate = [
|
|
||||||
lastDayOfMonth.getFullYear(),
|
|
||||||
String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"),
|
|
||||||
String(lastDayOfMonth.getDate()).padStart(2, "0"),
|
|
||||||
].join("-");
|
|
||||||
|
|
||||||
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
@ -358,7 +321,7 @@ export class SimCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send admin notification email
|
// Send admin notification email
|
||||||
const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({
|
const adminEmailBody = this.simNotification.buildCancellationAdminEmail({
|
||||||
customerName,
|
customerName,
|
||||||
simNumber: account,
|
simNumber: account,
|
||||||
serialNumber: simDetails.iccid,
|
serialNumber: simDetails.iccid,
|
||||||
@ -367,7 +330,7 @@ export class SimCancellationService {
|
|||||||
comments: request.comments,
|
comments: request.comments,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.apiNotification.sendApiResultsEmail(
|
await this.simNotification.sendApiResultsEmail(
|
||||||
"SonixNet SIM Online Cancellation",
|
"SonixNet SIM Online Cancellation",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@ -402,7 +365,7 @@ Assist Solutions Customer Support
|
|||||||
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
Email: info@asolutions.co.jp`;
|
Email: info@asolutions.co.jp`;
|
||||||
|
|
||||||
await this.apiNotification.sendCustomerEmail(
|
await this.simNotification.sendCustomerEmail(
|
||||||
customerEmail,
|
customerEmail,
|
||||||
confirmationSubject,
|
confirmationSubject,
|
||||||
confirmationBody
|
confirmationBody
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimDetails } from "@customer-portal/domain/sim";
|
import type { SimDetails } from "@customer-portal/domain/sim";
|
||||||
@ -8,7 +8,7 @@ import type { SimDetails } from "@customer-portal/domain/sim";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimDetailsService {
|
export class SimDetailsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@ -5,6 +5,25 @@ import { EmailService } from "@bff/infra/email/email.service.js";
|
|||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimNotificationContext } from "../interfaces/sim-base.interface.js";
|
import type { SimNotificationContext } from "../interfaces/sim-base.interface.js";
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API call log structure for notification emails
|
||||||
|
*/
|
||||||
|
export interface ApiCallLog {
|
||||||
|
url: string;
|
||||||
|
senddata?: Record<string, unknown> | string;
|
||||||
|
json?: Record<string, unknown> | string;
|
||||||
|
result: Record<string, unknown> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified SIM notification service.
|
||||||
|
* Handles all SIM-related email notifications including:
|
||||||
|
* - Internal action notifications (success/error alerts)
|
||||||
|
* - API results notifications to admin
|
||||||
|
* - Customer-facing emails (eSIM reissue, cancellation confirmations)
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimNotificationService {
|
export class SimNotificationService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -13,8 +32,12 @@ export class SimNotificationService {
|
|||||||
private readonly configService: ConfigService
|
private readonly configService: ConfigService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal Action Notifications
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send notification for SIM actions
|
* Send notification for SIM actions to configured alert email
|
||||||
*/
|
*/
|
||||||
async notifySimAction(
|
async notifySimAction(
|
||||||
action: string,
|
action: string,
|
||||||
@ -59,27 +82,169 @@ export class SimNotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Results Notifications (Admin)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redact sensitive information from notification context
|
* Send API results notification email to admin
|
||||||
*/
|
*/
|
||||||
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
async sendApiResultsEmail(
|
||||||
const sanitized: Record<string, unknown> = {};
|
subject: string,
|
||||||
for (const [key, value] of Object.entries(context)) {
|
apiCalls: ApiCallLog[],
|
||||||
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
additionalInfo?: string
|
||||||
sanitized[key] = "[REDACTED]";
|
): Promise<void> {
|
||||||
continue;
|
try {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const call of apiCalls) {
|
||||||
|
lines.push(`url: ${call.url}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (call.senddata) {
|
||||||
|
const senddataStr =
|
||||||
|
typeof call.senddata === "string"
|
||||||
|
? call.senddata
|
||||||
|
: JSON.stringify(call.senddata, null, 2);
|
||||||
|
lines.push(`senddata: ${senddataStr}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.json) {
|
||||||
|
const jsonStr =
|
||||||
|
typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2);
|
||||||
|
lines.push(`json: ${jsonStr}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultStr =
|
||||||
|
typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2);
|
||||||
|
lines.push(`result: ${resultStr}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("---");
|
||||||
|
lines.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string" && value.length > 200) {
|
if (additionalInfo) {
|
||||||
sanitized[key] = `${value.substring(0, 200)}…`;
|
lines.push(additionalInfo);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitized[key] = value;
|
await this.email.sendEmail({
|
||||||
|
to: ADMIN_EMAIL,
|
||||||
|
from: ADMIN_EMAIL,
|
||||||
|
subject,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Sent API results notification email", {
|
||||||
|
subject,
|
||||||
|
to: ADMIN_EMAIL,
|
||||||
|
callCount: apiCalls.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send API results notification email", {
|
||||||
|
subject,
|
||||||
|
error: extractErrorMessage(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return sanitized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Customer Notifications
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send customer notification email
|
||||||
|
*/
|
||||||
|
async sendCustomerEmail(to: string, subject: string, body: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.email.sendEmail({
|
||||||
|
to,
|
||||||
|
from: ADMIN_EMAIL,
|
||||||
|
subject,
|
||||||
|
text: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Sent customer notification email", {
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send customer notification email", {
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
error: extractErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Body Builders
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build eSIM reissue customer email body
|
||||||
|
*/
|
||||||
|
buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string {
|
||||||
|
return `Dear ${customerName},
|
||||||
|
|
||||||
|
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||||
|
to the EID=${newEid} has been accepted.
|
||||||
|
|
||||||
|
Please download the SIM plan, then follow the instructions to install the APN profile.
|
||||||
|
|
||||||
|
eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf
|
||||||
|
APN profile instructions: https://www.asolutions.co.jp/sim-card/
|
||||||
|
|
||||||
|
With best regards,
|
||||||
|
Assist Solutions Customer Support
|
||||||
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
|
Email: ${ADMIN_EMAIL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build physical SIM reissue customer email body
|
||||||
|
*/
|
||||||
|
buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string {
|
||||||
|
return `Dear ${customerName},
|
||||||
|
|
||||||
|
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||||
|
as a physical SIM has been accepted.
|
||||||
|
|
||||||
|
You will be contacted by us again as soon as details about the shipping
|
||||||
|
schedule can be disclosed (typically in 3-5 business days).
|
||||||
|
|
||||||
|
With best regards,
|
||||||
|
Assist Solutions Customer Support
|
||||||
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
|
Email: ${ADMIN_EMAIL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build cancellation notification email body for admin
|
||||||
|
*/
|
||||||
|
buildCancellationAdminEmail(params: {
|
||||||
|
customerName: string;
|
||||||
|
simNumber: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
cancellationMonth: string;
|
||||||
|
registeredEmail: string;
|
||||||
|
comments?: string;
|
||||||
|
}): string {
|
||||||
|
return `The following SONIXNET SIM cancellation has been requested.
|
||||||
|
|
||||||
|
Customer name: ${params.customerName}
|
||||||
|
SIM #: ${params.simNumber}
|
||||||
|
Serial #: ${params.serialNumber || "N/A"}
|
||||||
|
Cancellation month: ${params.cancellationMonth}
|
||||||
|
Registered email address: ${params.registeredEmail}
|
||||||
|
Comments: ${params.comments || "N/A"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Message Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert technical errors to user-friendly messages for SIM operations
|
* Convert technical errors to user-friendly messages for SIM operations
|
||||||
*/
|
*/
|
||||||
@ -116,4 +281,65 @@ export class SimNotificationService {
|
|||||||
// Default fallback
|
// Default fallback
|
||||||
return "SIM operation failed. Please try again or contact support.";
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Action Runner (for wrapping operations with notifications)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an operation with automatic success/error notifications.
|
||||||
|
* Replaces the separate SimActionRunnerService.
|
||||||
|
*/
|
||||||
|
async runWithNotification<T>(
|
||||||
|
action: string,
|
||||||
|
options: {
|
||||||
|
baseContext: SimNotificationContext;
|
||||||
|
enrichSuccess?: (result: T) => Partial<SimNotificationContext>;
|
||||||
|
enrichError?: (error: unknown) => Partial<SimNotificationContext>;
|
||||||
|
},
|
||||||
|
handler: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const result = await handler();
|
||||||
|
const successContext = {
|
||||||
|
...options.baseContext,
|
||||||
|
...(options.enrichSuccess ? options.enrichSuccess(result) : {}),
|
||||||
|
};
|
||||||
|
await this.notifySimAction(action, "SUCCESS", successContext);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorContext = {
|
||||||
|
...options.baseContext,
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
...(options.enrichError ? options.enrichError(error) : {}),
|
||||||
|
};
|
||||||
|
await this.notifySimAction(action, "ERROR", errorContext);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive information from notification context
|
||||||
|
*/
|
||||||
|
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
||||||
|
sanitized[key] = "[REDACTED]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.length > 200) {
|
||||||
|
sanitized[key] = `${value.substring(0, 200)}…`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import type {
|
import type {
|
||||||
SimPlanChangeRequest,
|
SimPlanChangeRequest,
|
||||||
@ -10,10 +10,9 @@ import type {
|
|||||||
SimAvailablePlan,
|
SimAvailablePlan,
|
||||||
} from "@customer-portal/domain/sim";
|
} from "@customer-portal/domain/sim";
|
||||||
import { SimScheduleService } from "./sim-schedule.service.js";
|
import { SimScheduleService } from "./sim-schedule.service.js";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
|
||||||
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
|
import { SimManagementQueueService } from "../queue/sim-management.queue.js";
|
||||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
import { SimNotificationService } from "./sim-notification.service.js";
|
||||||
import { SimServicesService } from "@bff/modules/services/services/sim-services.service.js";
|
import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js";
|
||||||
|
|
||||||
// Mapping from Salesforce SKU to Freebit plan code
|
// Mapping from Salesforce SKU to Freebit plan code
|
||||||
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
||||||
@ -36,12 +35,11 @@ const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimPlanService {
|
export class SimPlanService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
|
||||||
private readonly simQueue: SimManagementQueueService,
|
private readonly simQueue: SimManagementQueueService,
|
||||||
private readonly apiNotification: SimApiNotificationService,
|
private readonly simNotification: SimNotificationService,
|
||||||
private readonly simCatalog: SimServicesService,
|
private readonly simCatalog: SimServicesService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
@ -112,7 +110,7 @@ export class SimPlanService {
|
|||||||
let account = "";
|
let account = "";
|
||||||
const assignGlobalIp = request.assignGlobalIp ?? false;
|
const assignGlobalIp = request.assignGlobalIp ?? false;
|
||||||
|
|
||||||
const response = await this.simActionRunner.run(
|
const response = await this.simNotification.runWithNotification(
|
||||||
"Change Plan",
|
"Change Plan",
|
||||||
{
|
{
|
||||||
baseContext: {
|
baseContext: {
|
||||||
@ -226,7 +224,7 @@ export class SimPlanService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send API results email
|
// Send API results email
|
||||||
await this.apiNotification.sendApiResultsEmail(
|
await this.simNotification.sendApiResultsEmail(
|
||||||
"API results - Plan Change",
|
"API results - Plan Change",
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@ -265,7 +263,7 @@ export class SimPlanService {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let account = "";
|
let account = "";
|
||||||
|
|
||||||
await this.simActionRunner.run(
|
await this.simNotification.runWithNotification(
|
||||||
"Update Features",
|
"Update Features",
|
||||||
{
|
{
|
||||||
baseContext: {
|
baseContext: {
|
||||||
|
|||||||
@ -1,25 +1,23 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||||
import { SimBillingService } from "./sim-billing.service.js";
|
import { SimBillingService } from "./sim-billing.service.js";
|
||||||
import { SimActionRunnerService } from "./sim-action-runner.service.js";
|
import { SimNotificationService } from "./sim-notification.service.js";
|
||||||
import { SimApiNotificationService } from "./sim-api-notification.service.js";
|
|
||||||
import { SimTopUpPricingService } from "./sim-topup-pricing.service.js";
|
import { SimTopUpPricingService } from "./sim-topup-pricing.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimTopUpService {
|
export class SimTopUpService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simBilling: SimBillingService,
|
private readonly simBilling: SimBillingService,
|
||||||
private readonly simActionRunner: SimActionRunnerService,
|
private readonly simNotification: SimNotificationService,
|
||||||
private readonly apiNotification: SimApiNotificationService,
|
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly simTopUpPricing: SimTopUpPricingService,
|
private readonly simTopUpPricing: SimTopUpPricingService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
@ -43,7 +41,7 @@ export class SimTopUpService {
|
|||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
let latestAccount = "";
|
let latestAccount = "";
|
||||||
|
|
||||||
await this.simActionRunner.run(
|
await this.simNotification.runWithNotification(
|
||||||
"Top Up Data",
|
"Top Up Data",
|
||||||
{
|
{
|
||||||
baseContext: {
|
baseContext: {
|
||||||
@ -143,7 +141,7 @@ export class SimTopUpService {
|
|||||||
.toISOString()
|
.toISOString()
|
||||||
.split("T")[0];
|
.split("T")[0];
|
||||||
|
|
||||||
await this.apiNotification.sendApiResultsEmail("API results", [
|
await this.simNotification.sendApiResultsEmail("API results", [
|
||||||
{
|
{
|
||||||
url: this.whmcsBaseUrl,
|
url: this.whmcsBaseUrl,
|
||||||
senddata: {
|
senddata: {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, Inject } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { SimValidationService } from "./sim-validation.service.js";
|
import { SimValidationService } from "./sim-validation.service.js";
|
||||||
import { SimUsageStoreService } from "../../sim-usage-store.service.js";
|
import { SimUsageStoreService } from "../../sim-usage-store.service.js";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
@ -11,7 +11,7 @@ import { SimScheduleService } from "./sim-schedule.service.js";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimUsageService {
|
export class SimUsageService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOperationsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly usageStore: SimUsageStoreService,
|
private readonly usageStore: SimUsageStoreService,
|
||||||
private readonly simSchedule: SimScheduleService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
|
|||||||
@ -22,13 +22,13 @@ import { SimCancellationService } from "./services/sim-cancellation.service.js";
|
|||||||
import { EsimManagementService } from "./services/esim-management.service.js";
|
import { EsimManagementService } from "./services/esim-management.service.js";
|
||||||
import { SimValidationService } from "./services/sim-validation.service.js";
|
import { SimValidationService } from "./services/sim-validation.service.js";
|
||||||
import { SimNotificationService } from "./services/sim-notification.service.js";
|
import { SimNotificationService } from "./services/sim-notification.service.js";
|
||||||
import { SimApiNotificationService } from "./services/sim-api-notification.service.js";
|
|
||||||
import { SimBillingService } from "./services/sim-billing.service.js";
|
import { SimBillingService } from "./services/sim-billing.service.js";
|
||||||
import { SimScheduleService } from "./services/sim-schedule.service.js";
|
import { SimScheduleService } from "./services/sim-schedule.service.js";
|
||||||
import { SimActionRunnerService } from "./services/sim-action-runner.service.js";
|
|
||||||
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
import { SimManagementQueueService } from "./queue/sim-management.queue.js";
|
||||||
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
import { SimManagementProcessor } from "./queue/sim-management.processor.js";
|
||||||
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
import { SimCallHistoryService } from "./services/sim-call-history.service.js";
|
||||||
|
import { SimCallHistoryParserService } from "./services/sim-call-history-parser.service.js";
|
||||||
|
import { SimCallHistoryFormatterService } from "./services/sim-call-history-formatter.service.js";
|
||||||
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
import { ServicesModule } from "@bff/modules/services/services.module.js";
|
||||||
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js";
|
||||||
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js";
|
||||||
@ -58,7 +58,6 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
|
|||||||
// SIM management services
|
// SIM management services
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
SimApiNotificationService,
|
|
||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
SimTopUpService,
|
SimTopUpService,
|
||||||
@ -69,9 +68,11 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
|
|||||||
SimOrchestratorService,
|
SimOrchestratorService,
|
||||||
SimBillingService,
|
SimBillingService,
|
||||||
SimScheduleService,
|
SimScheduleService,
|
||||||
SimActionRunnerService,
|
|
||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
SimManagementProcessor,
|
SimManagementProcessor,
|
||||||
|
// Call history services (split for maintainability)
|
||||||
|
SimCallHistoryParserService,
|
||||||
|
SimCallHistoryFormatterService,
|
||||||
SimCallHistoryService,
|
SimCallHistoryService,
|
||||||
// Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService
|
// Backwards compatibility alias: SimVoiceOptionsService -> VoiceOptionsService
|
||||||
{
|
{
|
||||||
@ -91,10 +92,8 @@ import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-opti
|
|||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
SimApiNotificationService,
|
|
||||||
SimBillingService,
|
SimBillingService,
|
||||||
SimScheduleService,
|
SimScheduleService,
|
||||||
SimActionRunnerService,
|
|
||||||
SimManagementQueueService,
|
SimManagementQueueService,
|
||||||
SimCallHistoryService,
|
SimCallHistoryService,
|
||||||
// VoiceOptionsService is exported from VoiceOptionsModule
|
// VoiceOptionsService is exported from VoiceOptionsModule
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Injectable, BadRequestException, Inject, ConflictException } from "@nestjs/common";
|
import { Injectable, BadRequestException, Inject, ConflictException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
|
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.service.js";
|
||||||
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
import { CacheService } from "@bff/infra/cache/cache.service.js";
|
||||||
@ -13,7 +13,7 @@ import { SimScheduleService } from "./sim-management/services/sim-schedule.servi
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimOrderActivationService {
|
export class SimOrderActivationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebit: FreebitOrchestratorService,
|
private readonly freebit: FreebitOperationsService,
|
||||||
private readonly whmcsOrderService: WhmcsOrderService,
|
private readonly whmcsOrderService: WhmcsOrderService,
|
||||||
private readonly mappings: MappingsService,
|
private readonly mappings: MappingsService,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { Logger } from "nestjs-pino";
|
|||||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service.js";
|
||||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
|
||||||
import { ServicesCacheService } from "@bff/modules/services/services/services-cache.service.js";
|
import { ServicesCacheService } from "@bff/modules/services/application/services-cache.service.js";
|
||||||
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers";
|
||||||
import {
|
import {
|
||||||
assertSalesforceId,
|
assertSalesforceId,
|
||||||
|
|||||||
@ -23,6 +23,7 @@
|
|||||||
"@tanstack/react-query": "^5.90.14",
|
"@tanstack/react-query": "^5.90.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"geist": "^1.5.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@ -9,48 +9,58 @@
|
|||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
DESIGN TOKENS
|
DESIGN TOKENS
|
||||||
|
|
||||||
Only define CSS variables here. Tailwind @theme maps them to utility classes.
|
Only define CSS variables here. Tailwind @theme maps them to utility classes.
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
|
||||||
/* Core */
|
/* Typography */
|
||||||
--background: oklch(1 0 0);
|
--font-sans: var(--font-geist-sans, system-ui, sans-serif);
|
||||||
--foreground: oklch(0.16 0 0);
|
--font-display: var(--font-plus-jakarta-sans, var(--font-sans));
|
||||||
|
|
||||||
|
/* Core Surfaces */
|
||||||
|
--background: oklch(0.995 0 0);
|
||||||
|
--foreground: oklch(0.13 0.02 265);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-foreground: var(--foreground);
|
||||||
--muted: oklch(0.96 0.008 234.4);
|
--muted: oklch(0.97 0.006 265);
|
||||||
--muted-foreground: oklch(0.5 0 0);
|
--muted-foreground: oklch(0.45 0.02 265);
|
||||||
|
|
||||||
/* Brand */
|
/* Brand - Premium Blue (shifted slightly purple for richness) */
|
||||||
--primary: oklch(0.6884 0.1342 234.4);
|
--primary: oklch(0.55 0.18 260);
|
||||||
|
--primary-hover: oklch(0.48 0.19 260);
|
||||||
|
--primary-soft: oklch(0.95 0.03 260);
|
||||||
--primary-foreground: oklch(0.99 0 0);
|
--primary-foreground: oklch(0.99 0 0);
|
||||||
--secondary: oklch(0.95 0.015 234.4);
|
|
||||||
--secondary-foreground: oklch(0.29 0 0);
|
/* Gradient Accent - Purple for gradient mixing */
|
||||||
--accent: oklch(0.95 0.04 234.4);
|
--accent-gradient: oklch(0.58 0.2 290);
|
||||||
|
|
||||||
|
--secondary: oklch(0.96 0.01 265);
|
||||||
|
--secondary-foreground: oklch(0.25 0.02 265);
|
||||||
|
--accent: oklch(0.95 0.04 260);
|
||||||
--accent-foreground: var(--foreground);
|
--accent-foreground: var(--foreground);
|
||||||
|
|
||||||
/* 5 Semantic Colors (each: base, foreground, bg, border) */
|
/* 5 Semantic Colors (each: base, foreground, bg, border) */
|
||||||
--success: oklch(0.42 0.1 145);
|
--success: oklch(0.52 0.14 155);
|
||||||
--success-foreground: oklch(0.99 0 0);
|
--success-foreground: oklch(0.99 0 0);
|
||||||
--success-bg: oklch(0.98 0.02 145);
|
--success-bg: oklch(0.98 0.02 145);
|
||||||
--success-border: oklch(0.93 0.08 150);
|
--success-border: oklch(0.93 0.08 150);
|
||||||
|
|
||||||
--info: oklch(0.48 0.1 234.4);
|
--info: oklch(0.55 0.16 230);
|
||||||
--info-foreground: oklch(0.99 0 0);
|
--info-foreground: oklch(0.99 0 0);
|
||||||
--info-bg: oklch(0.97 0.02 234.4);
|
--info-bg: oklch(0.97 0.02 234.4);
|
||||||
--info-border: oklch(0.91 0.05 234.4);
|
--info-border: oklch(0.91 0.05 234.4);
|
||||||
|
|
||||||
--warning: oklch(0.45 0.12 55);
|
--warning: oklch(0.72 0.15 65);
|
||||||
--warning-foreground: oklch(0.99 0 0);
|
--warning-foreground: oklch(0.99 0 0);
|
||||||
--warning-bg: oklch(0.99 0.02 90);
|
--warning-bg: oklch(0.99 0.02 90);
|
||||||
--warning-border: oklch(0.92 0.12 90);
|
--warning-border: oklch(0.92 0.12 90);
|
||||||
|
|
||||||
--danger: oklch(0.42 0.15 12);
|
--danger: oklch(0.55 0.2 25);
|
||||||
--danger-foreground: oklch(0.99 0 0);
|
--danger-foreground: oklch(0.99 0 0);
|
||||||
--danger-bg: oklch(0.98 0.01 10);
|
--danger-bg: oklch(0.98 0.01 10);
|
||||||
--danger-border: oklch(0.89 0.06 10);
|
--danger-border: oklch(0.89 0.06 10);
|
||||||
@ -61,14 +71,15 @@
|
|||||||
--neutral-border: oklch(0.87 0.02 272.34);
|
--neutral-border: oklch(0.87 0.02 272.34);
|
||||||
|
|
||||||
/* Chrome */
|
/* Chrome */
|
||||||
--border: oklch(0.9 0.005 234.4);
|
--border: oklch(0.92 0.005 265);
|
||||||
--input: oklch(0.88 0.005 234.4);
|
--input: oklch(0.96 0.004 265);
|
||||||
--ring: oklch(0.72 0.12 234.4);
|
--ring: oklch(0.55 0.18 260 / 0.5);
|
||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar - Deep rich purple-blue */
|
||||||
--sidebar: oklch(0.2754 0.1199 272.34);
|
--sidebar: oklch(0.18 0.08 280);
|
||||||
--sidebar-foreground: oklch(1 0 0);
|
--sidebar-foreground: oklch(0.98 0 0);
|
||||||
--sidebar-border: oklch(0.36 0.1 272.34);
|
--sidebar-border: oklch(0.28 0.06 280);
|
||||||
|
--sidebar-active: oklch(0.99 0 0 / 0.15);
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
--header: oklch(1 0 0 / 0.95);
|
--header: oklch(1 0 0 / 0.95);
|
||||||
@ -81,21 +92,50 @@
|
|||||||
--chart-3: oklch(0.75 0.14 85);
|
--chart-3: oklch(0.75 0.14 85);
|
||||||
--chart-4: var(--danger);
|
--chart-4: var(--danger);
|
||||||
--chart-5: var(--neutral);
|
--chart-5: var(--neutral);
|
||||||
|
|
||||||
|
/* Glass Morphism Tokens */
|
||||||
|
--glass-bg: oklch(1 0 0 / 0.7);
|
||||||
|
--glass-bg-strong: oklch(1 0 0 / 0.85);
|
||||||
|
--glass-border: oklch(1 0 0 / 0.2);
|
||||||
|
--glass-blur: 12px;
|
||||||
|
--glass-blur-strong: 20px;
|
||||||
|
|
||||||
|
/* Gradient Presets */
|
||||||
|
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--accent-gradient) 100%);
|
||||||
|
--gradient-premium: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
oklch(0.55 0.18 260),
|
||||||
|
oklch(0.52 0.2 290),
|
||||||
|
oklch(0.55 0.15 320)
|
||||||
|
);
|
||||||
|
--gradient-subtle: linear-gradient(180deg, oklch(0.99 0.005 260) 0%, oklch(0.97 0.008 290) 100%);
|
||||||
|
--gradient-glow: radial-gradient(circle at 50% 0%, oklch(0.55 0.18 260 / 0.15), transparent 50%);
|
||||||
|
|
||||||
|
/* Premium Shadows with Color */
|
||||||
|
--shadow-primary-sm: 0 2px 8px -2px oklch(0.55 0.18 260 / 0.2);
|
||||||
|
--shadow-primary-md: 0 4px 16px -4px oklch(0.55 0.18 260 / 0.25);
|
||||||
|
--shadow-primary-lg: 0 8px 32px -8px oklch(0.55 0.18 260 / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.15 0.01 272.34);
|
/* Surfaces - Rich dark with blue undertone */
|
||||||
--foreground: oklch(0.98 0 0);
|
--background: oklch(0.12 0.015 280);
|
||||||
--card: oklch(0.18 0.01 272.34);
|
--foreground: oklch(0.95 0 0);
|
||||||
--popover: oklch(0.18 0.01 272.34);
|
--card: oklch(0.15 0.015 280);
|
||||||
--muted: oklch(0.25 0.01 272.34);
|
--card-foreground: var(--foreground);
|
||||||
|
--popover: oklch(0.15 0.015 280);
|
||||||
|
--popover-foreground: var(--foreground);
|
||||||
|
--muted: oklch(0.25 0.01 280);
|
||||||
--muted-foreground: oklch(0.74 0 0);
|
--muted-foreground: oklch(0.74 0 0);
|
||||||
|
|
||||||
--primary: oklch(0.76 0.12 234.4);
|
/* Brand - Brighter for dark mode contrast */
|
||||||
--primary-foreground: oklch(0.15 0 0);
|
--primary: oklch(0.68 0.16 260);
|
||||||
--secondary: oklch(0.22 0.01 272.34);
|
--primary-hover: oklch(0.72 0.14 260);
|
||||||
|
--primary-soft: oklch(0.22 0.04 260);
|
||||||
|
|
||||||
|
--secondary: oklch(0.22 0.01 280);
|
||||||
--secondary-foreground: oklch(0.9 0 0);
|
--secondary-foreground: oklch(0.9 0 0);
|
||||||
--accent: oklch(0.24 0.03 234.4);
|
--accent: oklch(0.24 0.03 260);
|
||||||
--accent-foreground: oklch(0.92 0 0);
|
--accent-foreground: oklch(0.92 0 0);
|
||||||
|
|
||||||
--success: oklch(0.72 0.1 145);
|
--success: oklch(0.72 0.1 145);
|
||||||
@ -123,17 +163,31 @@
|
|||||||
--neutral-bg: oklch(0.24 0.02 272.34);
|
--neutral-bg: oklch(0.24 0.02 272.34);
|
||||||
--neutral-border: oklch(0.38 0.03 272.34);
|
--neutral-border: oklch(0.38 0.03 272.34);
|
||||||
|
|
||||||
--border: oklch(0.32 0.02 272.34);
|
--border: oklch(0.32 0.02 280);
|
||||||
--input: oklch(0.35 0.02 272.34);
|
--input: oklch(0.35 0.02 280);
|
||||||
--ring: oklch(0.78 0.11 234.4);
|
--ring: oklch(0.68 0.16 260 / 0.5);
|
||||||
|
|
||||||
--sidebar: oklch(0.22 0.1199 272.34);
|
/* Sidebar - Slightly lighter than background */
|
||||||
--sidebar-border: oklch(0.3 0.08 272.34);
|
--sidebar: oklch(0.14 0.02 280);
|
||||||
|
--sidebar-border: oklch(0.22 0.03 280);
|
||||||
|
|
||||||
--header: oklch(0.18 0 0 / 0.95);
|
--header: oklch(0.15 0.015 280 / 0.95);
|
||||||
--header-foreground: var(--foreground);
|
--header-foreground: var(--foreground);
|
||||||
|
|
||||||
--chart-3: oklch(0.82 0.14 85);
|
--chart-3: oklch(0.82 0.14 85);
|
||||||
|
|
||||||
|
/* Glass for dark mode */
|
||||||
|
--glass-bg: oklch(0.15 0.02 280 / 0.6);
|
||||||
|
--glass-bg-strong: oklch(0.18 0.02 280 / 0.8);
|
||||||
|
--glass-border: oklch(1 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Gradients adjusted for dark */
|
||||||
|
--gradient-subtle: linear-gradient(180deg, oklch(0.15 0.02 260) 0%, oklch(0.13 0.025 290) 100%);
|
||||||
|
|
||||||
|
/* Shadows for dark mode */
|
||||||
|
--shadow-primary-sm: 0 2px 8px -2px oklch(0 0 0 / 0.4);
|
||||||
|
--shadow-primary-md: 0 4px 16px -4px oklch(0 0 0 / 0.5);
|
||||||
|
--shadow-primary-lg: 0 8px 32px -8px oklch(0 0 0 / 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@ -141,6 +195,11 @@
|
|||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
/* Font Families */
|
||||||
|
--font-family-sans: var(--font-sans);
|
||||||
|
--font-family-display: var(--font-display);
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@ -151,11 +210,14 @@
|
|||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-hover: var(--primary-hover);
|
||||||
|
--color-primary-soft: var(--primary-soft);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent-gradient: var(--accent-gradient);
|
||||||
|
|
||||||
--color-success: var(--success);
|
--color-success: var(--success);
|
||||||
--color-success-foreground: var(--success-foreground);
|
--color-success-foreground: var(--success-foreground);
|
||||||
@ -196,6 +258,7 @@
|
|||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-active: var(--sidebar-active);
|
||||||
|
|
||||||
--color-header: var(--header);
|
--color-header: var(--header);
|
||||||
--color-header-foreground: var(--header-foreground);
|
--color-header-foreground: var(--header-foreground);
|
||||||
@ -206,6 +269,10 @@
|
|||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
/* Glass tokens */
|
||||||
|
--color-glass-bg: var(--glass-bg);
|
||||||
|
--color-glass-border: var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@ -214,6 +281,6 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: var(--font-geist-sans, system-ui), system-ui, sans-serif;
|
font-family: var(--font-sans);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import { Plus_Jakarta_Sans } from "next/font/google";
|
||||||
|
import { GeistSans } from "geist/font/sans";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryProvider } from "@/core/providers";
|
import { QueryProvider } from "@/core/providers";
|
||||||
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
import { SessionTimeoutWarning } from "@/features/auth/components/SessionTimeoutWarning";
|
||||||
|
|
||||||
|
// Display font for headlines and hero text
|
||||||
|
const jakarta = Plus_Jakarta_Sans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-display",
|
||||||
|
display: "swap",
|
||||||
|
weight: ["500", "600", "700", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Assist Solutions Portal",
|
title: "Assist Solutions Portal",
|
||||||
description: "Manage your subscriptions, billing, and support with Assist Solutions",
|
description: "Manage your subscriptions, billing, and support with Assist Solutions",
|
||||||
@ -24,7 +34,7 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className="antialiased">
|
<body className={`${GeistSans.variable} ${jakarta.variable} antialiased`}>
|
||||||
<QueryProvider nonce={nonce}>
|
<QueryProvider nonce={nonce}>
|
||||||
{children}
|
{children}
|
||||||
<SessionTimeoutWarning />
|
<SessionTimeoutWarning />
|
||||||
|
|||||||
42
apps/portal/src/components/atoms/AnimatedContainer.tsx
Normal file
42
apps/portal/src/components/atoms/AnimatedContainer.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
|
interface AnimatedContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** Animation type */
|
||||||
|
animation?: "fade-up" | "fade-scale" | "slide-left" | "none";
|
||||||
|
/** Whether to stagger children animations */
|
||||||
|
stagger?: boolean;
|
||||||
|
/** Delay before animation starts in ms */
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable animation wrapper component
|
||||||
|
* Provides consistent entrance animations for page content
|
||||||
|
*/
|
||||||
|
export function AnimatedContainer({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
animation = "fade-up",
|
||||||
|
stagger = false,
|
||||||
|
delay = 0,
|
||||||
|
}: AnimatedContainerProps) {
|
||||||
|
const animationClass = {
|
||||||
|
"fade-up": "cp-animate-in",
|
||||||
|
"fade-scale": "cp-animate-scale-in",
|
||||||
|
"slide-left": "cp-animate-slide-left",
|
||||||
|
none: "",
|
||||||
|
}[animation];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(animationClass, stagger && "cp-stagger-children", className)}
|
||||||
|
style={delay > 0 ? { animationDelay: `${delay}ms` } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@ import { cn } from "@/shared/utils";
|
|||||||
import { Spinner } from "./Spinner";
|
import { Spinner } from "./Spinner";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98]",
|
"group inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-[var(--cp-duration-normal)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:pointer-events-none hover:scale-[1.01] active:scale-[0.98]",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@ -52,3 +52,6 @@ export { Logo } from "./logo";
|
|||||||
|
|
||||||
// Navigation and Steps
|
// Navigation and Steps
|
||||||
export { StepHeader } from "./step-header";
|
export { StepHeader } from "./step-header";
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
export { AnimatedContainer } from "./AnimatedContainer";
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
type Tone = "info" | "success" | "warning" | "error";
|
type Tone = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
@ -15,24 +16,27 @@ export function InlineToast({
|
|||||||
className = "",
|
className = "",
|
||||||
...rest
|
...rest
|
||||||
}: InlineToastProps) {
|
}: InlineToastProps) {
|
||||||
const toneClasses =
|
const toneClasses = {
|
||||||
tone === "success"
|
success: "bg-success-bg border-success-border text-success",
|
||||||
? "bg-green-50 border-green-200 text-green-800"
|
warning: "bg-warning-bg border-warning-border text-warning",
|
||||||
: tone === "warning"
|
error: "bg-danger-bg border-danger-border text-danger",
|
||||||
? "bg-amber-50 border-amber-200 text-amber-800"
|
info: "bg-info-bg border-info-border text-info",
|
||||||
: tone === "error"
|
}[tone];
|
||||||
? "bg-red-50 border-red-200 text-red-800"
|
|
||||||
: "bg-blue-50 border-blue-200 text-blue-800";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-6 right-6 z-50 transition-all duration-200 ${
|
className={cn(
|
||||||
visible ? "opacity-100 translate-y-0" : "opacity-0 pointer-events-none translate-y-2"
|
"fixed bottom-6 right-6 z-50",
|
||||||
} ${className}`}
|
visible ? "cp-toast-enter" : "cp-toast-exit pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg min-w-[220px] text-sm ${toneClasses}`}
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-lg border px-4 py-3 shadow-lg min-w-[240px] text-sm font-medium",
|
||||||
|
toneClasses
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>{text}</span>
|
<span>{text}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,14 +14,15 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-lg border border-border bg-card text-foreground px-4 py-2.5 text-sm shadow-sm ring-offset-background transition-all duration-200",
|
"flex h-11 w-full rounded-lg border border-border bg-card text-foreground px-4 py-2.5 text-sm shadow-sm ring-offset-background",
|
||||||
|
"cp-input-focus",
|
||||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||||
"placeholder:text-muted-foreground",
|
"placeholder:text-muted-foreground",
|
||||||
"hover:border-muted-foreground/50",
|
"hover:border-muted-foreground/50",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-0 focus-visible:border-primary",
|
"focus-visible:outline-none focus-visible:border-primary",
|
||||||
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border",
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border",
|
||||||
isInvalid &&
|
isInvalid &&
|
||||||
"border-danger hover:border-danger focus-visible:ring-danger/40 focus-visible:border-danger",
|
"border-danger hover:border-danger focus-visible:border-danger cp-input-error-shake",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
aria-invalid={isInvalid || undefined}
|
aria-invalid={isInvalid || undefined}
|
||||||
|
|||||||
@ -6,7 +6,9 @@ interface SkeletonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Skeleton({ className, animate = true }: SkeletonProps) {
|
export function Skeleton({ className, animate = true }: SkeletonProps) {
|
||||||
return <div className={cn("bg-muted rounded-md", animate && "animate-pulse", className)} />;
|
return (
|
||||||
|
<div className={cn("rounded-md", animate ? "cp-skeleton-shimmer" : "bg-muted", className)} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingCard({ className }: { className?: string }) {
|
export function LoadingCard({ className }: { className?: string }) {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ interface ActivityFeedProps {
|
|||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICON_COMPONENTS: Record<
|
const ICON_COMPONENTS: Record<
|
||||||
@ -108,16 +109,16 @@ function ActivityItem({ activity, isLast = false }: ActivityItemProps) {
|
|||||||
|
|
||||||
function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) {
|
function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 py-3 px-2 -mx-2 animate-pulse">
|
<div className="flex items-start gap-3 py-3 px-2 -mx-2">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="h-8 w-8 rounded-lg bg-muted" />
|
<div className="h-8 w-8 rounded-lg cp-skeleton-shimmer" />
|
||||||
{!isLast && (
|
{!isLast && (
|
||||||
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-muted" />
|
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-muted" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 pt-0.5 space-y-1.5">
|
<div className="flex-1 min-w-0 pt-0.5 space-y-1.5">
|
||||||
<div className="h-4 bg-muted rounded w-3/4" />
|
<div className="h-4 cp-skeleton-shimmer rounded w-3/4" />
|
||||||
<div className="h-3 bg-muted rounded w-16" />
|
<div className="h-3 cp-skeleton-shimmer rounded w-16" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -153,10 +154,11 @@ export function ActivityFeed({
|
|||||||
maxItems = 5,
|
maxItems = 5,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
}: ActivityFeedProps) {
|
}: ActivityFeedProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)} style={style}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -172,7 +174,7 @@ export function ActivityFeed({
|
|||||||
const visibleActivities = activities.slice(0, maxItems);
|
const visibleActivities = activities.slice(0, maxItems);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)} style={style}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -180,7 +182,7 @@ export function ActivityFeed({
|
|||||||
{visibleActivities.length === 0 ? (
|
{visibleActivities.length === 0 ? (
|
||||||
<EmptyActivity />
|
<EmptyActivity />
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-surface border border-border/60 rounded-xl p-4">
|
<div className="bg-surface border border-border/60 rounded-xl p-4 cp-stagger-children">
|
||||||
{visibleActivities.map((activity, index) => (
|
{visibleActivities.map((activity, index) => (
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
key={activity.id}
|
key={activity.id}
|
||||||
|
|||||||
@ -61,14 +61,15 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText
|
|||||||
"group flex items-center gap-4 p-4 rounded-xl",
|
"group flex items-center gap-4 p-4 rounded-xl",
|
||||||
"bg-surface border border-border/60",
|
"bg-surface border border-border/60",
|
||||||
"transition-all duration-[var(--cp-duration-normal)]",
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
"hover:shadow-md",
|
"hover:shadow-md hover:-translate-y-0.5",
|
||||||
styles.hoverBorder
|
styles.hoverBorder
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center",
|
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center",
|
||||||
"transition-colors",
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"group-hover:scale-105",
|
||||||
styles.iconBg
|
styles.iconBg
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -89,13 +90,13 @@ function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText
|
|||||||
|
|
||||||
function StatItemSkeleton() {
|
function StatItemSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 p-4 rounded-xl bg-surface border border-border/60 animate-pulse">
|
<div className="flex items-center gap-4 p-4 rounded-xl bg-surface border border-border/60">
|
||||||
<div className="flex-shrink-0 h-11 w-11 rounded-xl bg-muted" />
|
<div className="flex-shrink-0 h-11 w-11 rounded-xl cp-skeleton-shimmer" />
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<div className="h-3 bg-muted rounded w-20" />
|
<div className="h-3 cp-skeleton-shimmer rounded w-20" />
|
||||||
<div className="h-7 bg-muted rounded w-10" />
|
<div className="h-7 cp-skeleton-shimmer rounded w-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-4 bg-muted rounded flex-shrink-0" />
|
<div className="h-4 w-4 cp-skeleton-shimmer rounded flex-shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -130,7 +131,7 @@ export function QuickStats({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
|
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 cp-stagger-children">
|
||||||
<StatItem
|
<StatItem
|
||||||
icon={ServerIcon}
|
icon={ServerIcon}
|
||||||
label="Active Services"
|
label="Active Services"
|
||||||
|
|||||||
@ -75,20 +75,20 @@ export function DashboardView() {
|
|||||||
<PageLayout title="Dashboard" description="Overview of your account" loading>
|
<PageLayout title="Dashboard" description="Overview of your account" loading>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Greeting skeleton */}
|
{/* Greeting skeleton */}
|
||||||
<div className="animate-pulse">
|
<div className="space-y-3">
|
||||||
<div className="h-4 bg-muted rounded w-24 mb-3" />
|
<div className="h-4 cp-skeleton-shimmer rounded w-24" />
|
||||||
<div className="h-10 bg-muted rounded w-56 mb-2" />
|
<div className="h-10 cp-skeleton-shimmer rounded w-56" />
|
||||||
<div className="h-4 bg-muted rounded w-40" />
|
<div className="h-4 cp-skeleton-shimmer rounded w-40" />
|
||||||
</div>
|
</div>
|
||||||
{/* Tasks skeleton */}
|
{/* Tasks skeleton */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="h-24 bg-muted rounded-2xl" />
|
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
|
||||||
<div className="h-24 bg-muted rounded-2xl" />
|
<div className="h-24 cp-skeleton-shimmer rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
{/* Bottom section skeleton */}
|
{/* Bottom section skeleton */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="h-44 bg-muted rounded-2xl" />
|
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
|
||||||
<div className="h-44 bg-muted rounded-2xl" />
|
<div className="h-44 cp-skeleton-shimmer rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -128,12 +128,25 @@ export function DashboardView() {
|
|||||||
/>
|
/>
|
||||||
{/* Greeting Section */}
|
{/* Greeting Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<p className="text-sm font-medium text-muted-foreground">Welcome back</p>
|
<p
|
||||||
<h2 className="text-3xl sm:text-4xl font-bold text-foreground mt-1">{displayName}</h2>
|
className="text-sm font-medium text-muted-foreground animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||||
|
style={{ animationDelay: "0ms" }}
|
||||||
|
>
|
||||||
|
Welcome back
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
className="text-3xl sm:text-4xl font-bold text-foreground mt-1 font-display animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
|
style={{ animationDelay: "50ms" }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{/* Task status badge */}
|
{/* Task status badge */}
|
||||||
{taskCount > 0 ? (
|
{taskCount > 0 ? (
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div
|
||||||
|
className="flex items-center gap-2 mt-3 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
|
style={{ animationDelay: "100ms" }}
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
|
||||||
@ -145,12 +158,21 @@ export function DashboardView() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground mt-2">Everything is up to date</p>
|
<p
|
||||||
|
className="text-sm text-muted-foreground mt-2 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
|
style={{ animationDelay: "100ms" }}
|
||||||
|
>
|
||||||
|
Everything is up to date
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks Section - Main focus area */}
|
{/* Tasks Section - Main focus area */}
|
||||||
<section className="mb-10" aria-labelledby="tasks-heading">
|
<section
|
||||||
|
className="mb-10 animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
|
style={{ animationDelay: "150ms" }}
|
||||||
|
aria-labelledby="tasks-heading"
|
||||||
|
>
|
||||||
<h3 id="tasks-heading" className="sr-only">
|
<h3 id="tasks-heading" className="sr-only">
|
||||||
Your Tasks
|
Your Tasks
|
||||||
</h3>
|
</h3>
|
||||||
@ -158,17 +180,24 @@ export function DashboardView() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Bottom Section: Quick Stats + Recent Activity */}
|
{/* Bottom Section: Quick Stats + Recent Activity */}
|
||||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6" aria-label="Account overview">
|
<section
|
||||||
|
className="grid grid-cols-1 lg:grid-cols-2 gap-6 cp-stagger-children"
|
||||||
|
style={{ animationDelay: "200ms" }}
|
||||||
|
aria-label="Account overview"
|
||||||
|
>
|
||||||
<QuickStats
|
<QuickStats
|
||||||
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
|
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
|
||||||
openCases={summary?.stats?.openCases ?? 0}
|
openCases={summary?.stats?.openCases ?? 0}
|
||||||
recentOrders={summary?.stats?.recentOrders}
|
recentOrders={summary?.stats?.recentOrders}
|
||||||
isLoading={summaryLoading}
|
isLoading={summaryLoading}
|
||||||
|
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
/>
|
/>
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
activities={summary?.recentActivity || []}
|
activities={summary?.recentActivity || []}
|
||||||
maxItems={5}
|
maxItems={5}
|
||||||
isLoading={summaryLoading}
|
isLoading={summaryLoading}
|
||||||
|
className="animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
|
style={{ animationDelay: "100ms" }}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
|
||||||
|
interface AnimatedBackgroundProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mesh gradient background with floating geometric shapes
|
||||||
|
*/
|
||||||
|
export function AnimatedBackground({ className }: AnimatedBackgroundProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("absolute inset-0 -z-10 overflow-hidden", className)}>
|
||||||
|
{/* Mesh gradient */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0",
|
||||||
|
"bg-[radial-gradient(ellipse_at_20%_30%,_oklch(0.72_0.12_260_/_0.12)_0%,_transparent_50%),",
|
||||||
|
"radial-gradient(ellipse_at_80%_20%,_oklch(0.72_0.14_290_/_0.08)_0%,_transparent_50%),",
|
||||||
|
"radial-gradient(ellipse_at_60%_80%,_oklch(0.75_0.1_200_/_0.06)_0%,_transparent_50%)]"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: `
|
||||||
|
radial-gradient(ellipse at 20% 30%, oklch(0.72 0.12 260 / 0.12) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, oklch(0.72 0.14 290 / 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 60% 80%, oklch(0.75 0.1 200 / 0.06) 0%, transparent 50%)
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Floating shapes */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/4 left-1/4 w-64 h-64 rounded-full",
|
||||||
|
"bg-gradient-to-br from-primary/5 to-transparent",
|
||||||
|
"cp-float-slow blur-3xl"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-1/2 right-1/4 w-48 h-48 rounded-full",
|
||||||
|
"bg-gradient-to-br from-accent-gradient/5 to-transparent",
|
||||||
|
"cp-float-delayed blur-3xl"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-1/4 left-1/3 w-32 h-32 rounded-full",
|
||||||
|
"bg-gradient-to-br from-primary/8 to-transparent",
|
||||||
|
"cp-float blur-2xl"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Subtle grid pattern overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.02]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, currentColor 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: "64px 64px",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,192 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
type ServiceCardSize = "small" | "medium" | "large";
|
||||||
|
|
||||||
|
interface BentoServiceCardProps {
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** Accent color for the card - e.g., "blue", "green", "purple" */
|
||||||
|
accentColor: string;
|
||||||
|
size?: ServiceCardSize;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColorMap: Record<
|
||||||
|
string,
|
||||||
|
{ bg: string; border: string; text: string; hoverBorder: string }
|
||||||
|
> = {
|
||||||
|
blue: {
|
||||||
|
bg: "from-blue-500/10 via-card to-card",
|
||||||
|
border: "border-blue-500/20",
|
||||||
|
text: "text-blue-500",
|
||||||
|
hoverBorder: "hover:border-blue-500/40",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
bg: "from-green-500/10 via-card to-card",
|
||||||
|
border: "border-green-500/20",
|
||||||
|
text: "text-green-500",
|
||||||
|
hoverBorder: "hover:border-green-500/40",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
bg: "from-purple-500/10 via-card to-card",
|
||||||
|
border: "border-purple-500/20",
|
||||||
|
text: "text-purple-500",
|
||||||
|
hoverBorder: "hover:border-purple-500/40",
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
bg: "from-amber-500/10 via-card to-card",
|
||||||
|
border: "border-amber-500/20",
|
||||||
|
text: "text-amber-500",
|
||||||
|
hoverBorder: "hover:border-amber-500/40",
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
bg: "from-rose-500/10 via-card to-card",
|
||||||
|
border: "border-rose-500/20",
|
||||||
|
text: "text-rose-500",
|
||||||
|
hoverBorder: "hover:border-rose-500/40",
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
bg: "from-cyan-500/10 via-card to-card",
|
||||||
|
border: "border-cyan-500/20",
|
||||||
|
text: "text-cyan-500",
|
||||||
|
hoverBorder: "hover:border-cyan-500/40",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bento grid service card with size variants
|
||||||
|
*/
|
||||||
|
export function BentoServiceCard({
|
||||||
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
accentColor,
|
||||||
|
size = "medium",
|
||||||
|
className,
|
||||||
|
}: BentoServiceCardProps) {
|
||||||
|
const colors = accentColorMap[accentColor] || accentColorMap.blue;
|
||||||
|
|
||||||
|
if (size === "large") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"group relative overflow-hidden rounded-2xl",
|
||||||
|
"bg-gradient-to-br",
|
||||||
|
colors.bg,
|
||||||
|
"border",
|
||||||
|
colors.border,
|
||||||
|
colors.hoverBorder,
|
||||||
|
"p-8",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"hover:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Decorative gradient orb */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -top-20 -right-20 w-64 h-64 rounded-full",
|
||||||
|
"bg-gradient-to-br opacity-30",
|
||||||
|
accentColor === "blue" && "from-blue-500/30 to-transparent",
|
||||||
|
accentColor === "green" && "from-green-500/30 to-transparent",
|
||||||
|
accentColor === "purple" && "from-purple-500/30 to-transparent",
|
||||||
|
accentColor === "amber" && "from-amber-500/30 to-transparent",
|
||||||
|
accentColor === "rose" && "from-rose-500/30 to-transparent",
|
||||||
|
accentColor === "cyan" && "from-cyan-500/30 to-transparent",
|
||||||
|
"blur-2xl"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center h-14 w-14 rounded-xl mb-6",
|
||||||
|
"bg-background/50 backdrop-blur-sm border border-border/50",
|
||||||
|
colors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-bold text-foreground mb-3 font-display">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground leading-relaxed max-w-sm mb-6">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 font-semibold",
|
||||||
|
colors.text,
|
||||||
|
"transition-transform duration-[var(--cp-duration-normal)]",
|
||||||
|
"group-hover:translate-x-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size === "medium") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-xl bg-card border",
|
||||||
|
colors.border,
|
||||||
|
colors.hoverBorder,
|
||||||
|
"p-6",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"hover:-translate-y-0.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center h-12 w-12 rounded-lg mb-4",
|
||||||
|
"bg-gradient-to-br from-card to-muted",
|
||||||
|
colors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-2 font-display">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"group rounded-xl bg-card/80 backdrop-blur-sm border border-border/50",
|
||||||
|
"p-4",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"hover:bg-card hover:border-border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn("h-10 w-10 rounded-lg flex items-center justify-center", colors.text)}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-foreground">{title}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
interface FloatingGlassCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
className?: string;
|
||||||
|
/** Accent color for icon */
|
||||||
|
accentColor?: string;
|
||||||
|
/** Optional inline styles for animation delays */
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColorMap: Record<string, string> = {
|
||||||
|
blue: "text-blue-500",
|
||||||
|
green: "text-green-500",
|
||||||
|
purple: "text-purple-500",
|
||||||
|
amber: "text-amber-500",
|
||||||
|
rose: "text-rose-500",
|
||||||
|
cyan: "text-cyan-500",
|
||||||
|
primary: "text-primary",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorative floating glass card for hero section
|
||||||
|
*/
|
||||||
|
export function FloatingGlassCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className,
|
||||||
|
accentColor = "primary",
|
||||||
|
style,
|
||||||
|
}: FloatingGlassCardProps) {
|
||||||
|
const colorClass = accentColorMap[accentColor] || accentColorMap.primary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("cp-glass-card px-5 py-4 min-w-[200px]", "shadow-xl shadow-black/5", className)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-10 w-10 rounded-lg flex items-center justify-center",
|
||||||
|
"bg-background/50",
|
||||||
|
colorClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-foreground text-sm">{title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface GlowButtonProps {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant?: "primary" | "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premium button with animated glow effect on hover
|
||||||
|
*/
|
||||||
|
export function GlowButton({ href, children, className, variant = "primary" }: GlowButtonProps) {
|
||||||
|
if (variant === "secondary") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold",
|
||||||
|
"border border-border bg-background text-foreground",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"hover:-translate-y-0.5 active:translate-y-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className={cn("relative group", className)}>
|
||||||
|
{/* Glow layer */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -inset-1 rounded-xl blur-md",
|
||||||
|
"bg-gradient-to-r from-primary via-accent-gradient to-primary",
|
||||||
|
"opacity-50 group-hover:opacity-80",
|
||||||
|
"transition-opacity duration-[var(--cp-duration-slow)]"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold",
|
||||||
|
"bg-primary text-primary-foreground",
|
||||||
|
"shadow-lg shadow-primary/20",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"group-hover:-translate-y-0.5 group-hover:shadow-primary/30",
|
||||||
|
"group-active:translate-y-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import { cn } from "@/shared/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface ValuePropCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glass morphism value proposition card with gradient border on hover
|
||||||
|
*/
|
||||||
|
export function ValuePropCard({ icon: Icon, title, description, className }: ValuePropCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("group relative", className)}>
|
||||||
|
{/* Gradient border wrapper - visible on hover */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute -inset-0.5 rounded-2xl",
|
||||||
|
"bg-gradient-to-r from-primary/50 via-accent-gradient/50 to-primary/50",
|
||||||
|
"opacity-0 group-hover:opacity-100 blur-sm",
|
||||||
|
"transition-opacity duration-[var(--cp-duration-slowest)]"
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center text-center p-8",
|
||||||
|
"bg-card/80 dark:bg-card/60 backdrop-blur-sm",
|
||||||
|
"border border-border/50 rounded-2xl",
|
||||||
|
"transition-all duration-[var(--cp-duration-normal)]",
|
||||||
|
"group-hover:-translate-y-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon with animated background */}
|
||||||
|
<div className="relative h-16 w-16 mb-6">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl",
|
||||||
|
"bg-gradient-to-br from-primary/20 to-primary/5",
|
||||||
|
"transition-transform duration-[var(--cp-duration-slow)]",
|
||||||
|
"group-hover:scale-110"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="relative h-full w-full flex items-center justify-center text-primary">
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8",
|
||||||
|
"transition-transform duration-[var(--cp-duration-slow)]",
|
||||||
|
"group-hover:scale-110"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h3 className="text-xl font-bold text-foreground mb-3 font-display">{title}</h3>
|
||||||
|
<p className="text-muted-foreground leading-relaxed max-w-xs">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
export { GlowButton } from "./GlowButton";
|
||||||
|
export { ValuePropCard } from "./ValuePropCard";
|
||||||
|
export { BentoServiceCard } from "./BentoServiceCard";
|
||||||
|
export { FloatingGlassCard } from "./FloatingGlassCard";
|
||||||
|
export { AnimatedBackground } from "./AnimatedBackground";
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Wifi,
|
Wifi,
|
||||||
@ -10,205 +9,263 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Tv,
|
Tv,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
GlowButton,
|
||||||
|
ValuePropCard,
|
||||||
|
BentoServiceCard,
|
||||||
|
FloatingGlassCard,
|
||||||
|
AnimatedBackground,
|
||||||
|
} from "../components";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PublicLandingView - Marketing-focused landing page
|
* PublicLandingView - Modern SaaS Premium Landing Page
|
||||||
*
|
*
|
||||||
* Purpose: Hook visitors, build trust, guide to shop
|
* Purpose: Hook visitors, build trust, guide to shop
|
||||||
* Contains:
|
* Features:
|
||||||
* - Hero with tagline
|
* - Asymmetric hero with floating glass cards
|
||||||
* - Value props (One Stop Solution, English Support, Onsite Support) - ONLY here
|
* - Glass morphism value proposition cards
|
||||||
* - Brief service tease (links to /services)
|
* - Bento grid services section
|
||||||
* - CTA to contact
|
* - Glowing CTA with depth layers
|
||||||
*/
|
*/
|
||||||
export function PublicLandingView() {
|
export function PublicLandingView() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-20 pb-8">
|
<div className="space-y-24 pb-12">
|
||||||
{/* Hero Section */}
|
{/* ===== HERO SECTION ===== */}
|
||||||
<section className="text-center space-y-8 pt-8 sm:pt-16 relative">
|
<section className="relative min-h-[70vh] flex items-center pt-8 sm:pt-0">
|
||||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-primary/5 via-transparent to-transparent opacity-70 blur-3xl pointer-events-none" />
|
<AnimatedBackground />
|
||||||
|
|
||||||
<div className="space-y-6 max-w-4xl mx-auto px-4">
|
<div className="w-full grid lg:grid-cols-2 gap-12 lg:gap-8 items-center">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/5 border border-primary/10 px-3 py-1 text-sm text-primary font-medium mb-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
{/* Left: Content */}
|
||||||
<span className="relative flex h-2 w-2">
|
<div className="space-y-8">
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
{/* Badge */}
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
<div
|
||||||
</span>
|
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||||
Reliable Connectivity in Japan
|
style={{ animationDelay: "0ms" }}
|
||||||
</div>
|
>
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-extrabold tracking-tight text-foreground animate-in fade-in slide-in-from-bottom-6 duration-700">
|
<span className="inline-flex items-center gap-2 rounded-full bg-primary/5 border border-primary/10 px-4 py-1.5 text-sm text-primary font-medium">
|
||||||
A One Stop Solution
|
<span className="relative flex h-2 w-2">
|
||||||
<span className="block bg-gradient-to-r from-primary to-blue-600 bg-clip-text text-transparent pb-2 mt-2">
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
|
||||||
for Your IT Needs
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary" />
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
Reliable Connectivity in Japan
|
||||||
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700 delay-100">
|
</span>
|
||||||
Serving Japan's international community with reliable, English-supported internet,
|
|
||||||
mobile, and VPN solutions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4 animate-in fade-in slide-in-from-bottom-8 duration-700 delay-200">
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
Browse Services
|
|
||||||
<ArrowRight className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-4 text-lg font-semibold border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
Contact Us
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CONCEPT Section - Value Propositions (ONLY on homepage) */}
|
|
||||||
<section className="max-w-5xl mx-auto px-4">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h2 className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
|
||||||
Our Concept
|
|
||||||
</h2>
|
|
||||||
<p className="text-3xl font-bold text-foreground tracking-tight">
|
|
||||||
Why customers choose us
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{/* One Stop Solution */}
|
|
||||||
<div className="flex flex-col items-center text-center">
|
|
||||||
<div className="h-14 w-14 rounded-xl bg-primary/10 flex items-center justify-center mb-6 text-primary">
|
|
||||||
<BadgeCheck className="h-7 w-7" />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">One Stop Solution</h3>
|
|
||||||
<p className="text-muted-foreground leading-relaxed max-w-xs">
|
|
||||||
All you need is just to contact us and we will take care of everything.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* English Support */}
|
{/* Headline */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<div
|
||||||
<div className="h-14 w-14 rounded-xl bg-primary/10 flex items-center justify-center mb-6 text-primary">
|
className="space-y-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
|
||||||
<Globe className="h-7 w-7" />
|
style={{ animationDelay: "100ms" }}
|
||||||
|
>
|
||||||
|
<h1 className="text-display-lg lg:text-display-xl text-foreground">
|
||||||
|
A One Stop Solution
|
||||||
|
<span className="block cp-gradient-text pb-2 mt-2">for Your IT Needs</span>
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">English Support</h3>
|
|
||||||
<p className="text-muted-foreground leading-relaxed max-w-xs">
|
|
||||||
We always assist you in English. No language barrier to worry about.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Onsite Support */}
|
{/* Subtitle */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<p
|
||||||
<div className="h-14 w-14 rounded-xl bg-primary/10 flex items-center justify-center mb-6 text-primary">
|
className="text-lg sm:text-xl text-muted-foreground max-w-xl leading-relaxed animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||||
<Wrench className="h-7 w-7" />
|
style={{ animationDelay: "200ms" }}
|
||||||
</div>
|
>
|
||||||
<h3 className="text-xl font-bold text-foreground mb-3">Onsite Support</h3>
|
Serving Japan's international community with reliable, English-supported
|
||||||
<p className="text-muted-foreground leading-relaxed max-w-xs">
|
internet, mobile, and VPN solutions.
|
||||||
Our tech staff can visit your residence for setup and troubleshooting.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Services Teaser - Brief preview linking to /services */}
|
{/* CTAs */}
|
||||||
<section className="max-w-5xl mx-auto px-4 text-center">
|
<div
|
||||||
<h2 className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
className="flex flex-col sm:flex-row items-start sm:items-center gap-4 pt-2 animate-in fade-in slide-in-from-bottom-8 duration-700"
|
||||||
Our Services
|
style={{ animationDelay: "300ms" }}
|
||||||
</h2>
|
>
|
||||||
<p className="text-3xl font-bold text-foreground tracking-tight mb-8">What we offer</p>
|
<GlowButton href="/services">
|
||||||
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
|
||||||
<Link
|
|
||||||
href="/services/internet"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-blue-500/50 hover:bg-blue-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Wifi className="h-5 w-5 text-blue-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-blue-500 transition-colors">
|
|
||||||
Internet
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/sim"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-green-500/50 hover:bg-green-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Smartphone className="h-5 w-5 text-green-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-green-500 transition-colors">
|
|
||||||
SIM & eSIM
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/vpn"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-purple-500/50 hover:bg-purple-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Lock className="h-5 w-5 text-purple-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-purple-500 transition-colors">
|
|
||||||
VPN
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/business"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-amber-500/50 hover:bg-amber-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Building2 className="h-5 w-5 text-amber-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-amber-500 transition-colors">
|
|
||||||
Business
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/onsite"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-rose-500/50 hover:bg-rose-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Wrench className="h-5 w-5 text-rose-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-rose-500 transition-colors">
|
|
||||||
Onsite Support
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services/tv"
|
|
||||||
className="group inline-flex items-center gap-3 rounded-full border border-border bg-card px-5 py-3 hover:border-cyan-500/50 hover:bg-cyan-500/5 transition-all"
|
|
||||||
>
|
|
||||||
<Tv className="h-5 w-5 text-cyan-500" />
|
|
||||||
<span className="font-medium text-foreground group-hover:text-cyan-500 transition-colors">
|
|
||||||
TV Services
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className="inline-flex items-center gap-2 text-primary font-semibold hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
Browse all services
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
|
||||||
<section className="max-w-3xl mx-auto text-center px-4">
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-primary/5 via-primary/10 to-blue-600/5 border border-primary/20 p-8 sm:p-12">
|
|
||||||
<div className="absolute top-0 right-0 -translate-y-1/4 translate-x-1/4 w-64 h-64 bg-primary/10 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
<div className="absolute bottom-0 left-0 translate-y-1/4 -translate-x-1/4 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
|
|
||||||
<div className="relative">
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold text-foreground mb-4">
|
|
||||||
Ready to get connected?
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground max-w-lg mx-auto mb-8 leading-relaxed">
|
|
||||||
Contact us anytime — our bilingual team is here to help you find the right solution.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
|
||||||
<Link
|
|
||||||
href="/contact"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-3 text-base font-semibold bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg shadow-primary/20 transition-all"
|
|
||||||
>
|
|
||||||
Contact Us
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/services"
|
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-8 py-3 text-base font-semibold border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Browse Services
|
Browse Services
|
||||||
</Link>
|
<ArrowRight className="h-5 w-5 ml-1" />
|
||||||
|
</GlowButton>
|
||||||
|
<GlowButton href="/contact" variant="secondary">
|
||||||
|
Contact Us
|
||||||
|
</GlowButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Floating Glass Cards (hidden on mobile) */}
|
||||||
|
<div className="hidden lg:block relative h-[400px]">
|
||||||
|
<FloatingGlassCard
|
||||||
|
icon={Wifi}
|
||||||
|
title="High-Speed Internet"
|
||||||
|
subtitle="Fiber & WiFi solutions"
|
||||||
|
accentColor="blue"
|
||||||
|
className="absolute top-0 right-0 cp-float animate-in fade-in slide-in-from-right-12 duration-700"
|
||||||
|
style={{ animationDelay: "500ms" }}
|
||||||
|
/>
|
||||||
|
<FloatingGlassCard
|
||||||
|
icon={Smartphone}
|
||||||
|
title="Mobile SIM"
|
||||||
|
subtitle="Voice & data plans"
|
||||||
|
accentColor="green"
|
||||||
|
className="absolute top-1/3 right-1/4 cp-float-delayed animate-in fade-in slide-in-from-right-12 duration-700"
|
||||||
|
style={{ animationDelay: "650ms" }}
|
||||||
|
/>
|
||||||
|
<FloatingGlassCard
|
||||||
|
icon={Lock}
|
||||||
|
title="VPN Security"
|
||||||
|
subtitle="Privacy protection"
|
||||||
|
accentColor="purple"
|
||||||
|
className="absolute bottom-0 right-1/6 cp-float-slow animate-in fade-in slide-in-from-right-12 duration-700"
|
||||||
|
style={{ animationDelay: "800ms" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ===== VALUE PROPS SECTION ===== */}
|
||||||
|
<section className="max-w-5xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
||||||
|
Our Concept
|
||||||
|
</p>
|
||||||
|
<h2 className="text-display-md text-foreground">Why customers choose us</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 cp-stagger-children">
|
||||||
|
<ValuePropCard
|
||||||
|
icon={BadgeCheck}
|
||||||
|
title="One Stop Solution"
|
||||||
|
description="All you need is just to contact us and we will take care of everything."
|
||||||
|
/>
|
||||||
|
<ValuePropCard
|
||||||
|
icon={Globe}
|
||||||
|
title="English Support"
|
||||||
|
description="We always assist you in English. No language barrier to worry about."
|
||||||
|
/>
|
||||||
|
<ValuePropCard
|
||||||
|
icon={Wrench}
|
||||||
|
title="Onsite Support"
|
||||||
|
description="Our tech staff can visit your residence for setup and troubleshooting."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ===== SERVICES BENTO GRID ===== */}
|
||||||
|
<section className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<p className="text-sm font-semibold text-primary uppercase tracking-wider mb-3">
|
||||||
|
Our Services
|
||||||
|
</p>
|
||||||
|
<h2 className="text-display-md text-foreground">What we offer</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bento Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-6 gap-4 auto-rows-[minmax(180px,auto)] cp-stagger-children">
|
||||||
|
{/* Internet - Featured (spans 3 cols, 2 rows) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/internet"
|
||||||
|
icon={Wifi}
|
||||||
|
title="Internet"
|
||||||
|
description="High-speed fiber and WiFi solutions for homes and businesses across Japan."
|
||||||
|
accentColor="blue"
|
||||||
|
size="large"
|
||||||
|
className="md:col-span-3 md:row-span-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* SIM - Medium (spans 2 cols) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/sim"
|
||||||
|
icon={Smartphone}
|
||||||
|
title="SIM & eSIM"
|
||||||
|
description="Flexible voice and data plans with no contracts required."
|
||||||
|
accentColor="green"
|
||||||
|
size="medium"
|
||||||
|
className="md:col-span-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* VPN - Small (spans 1 col) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/vpn"
|
||||||
|
icon={Lock}
|
||||||
|
title="VPN"
|
||||||
|
accentColor="purple"
|
||||||
|
size="small"
|
||||||
|
className="md:col-span-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Business - Small (spans 1 col) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/business"
|
||||||
|
icon={Building2}
|
||||||
|
title="Business"
|
||||||
|
accentColor="amber"
|
||||||
|
size="small"
|
||||||
|
className="md:col-span-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Onsite - Small (spans 1 col) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/onsite"
|
||||||
|
icon={Wrench}
|
||||||
|
title="Onsite"
|
||||||
|
accentColor="rose"
|
||||||
|
size="small"
|
||||||
|
className="md:col-span-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TV - Medium (spans 4 cols) */}
|
||||||
|
<BentoServiceCard
|
||||||
|
href="/services/tv"
|
||||||
|
icon={Tv}
|
||||||
|
title="TV Services"
|
||||||
|
description="International TV packages and streaming solutions for your home entertainment."
|
||||||
|
accentColor="cyan"
|
||||||
|
size="medium"
|
||||||
|
className="md:col-span-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ===== CTA SECTION ===== */}
|
||||||
|
<section className="relative py-20 -mx-[var(--cp-page-padding)] px-[var(--cp-page-padding)]">
|
||||||
|
{/* Background layers */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-primary/10 to-accent-gradient/5" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_oklch(0.55_0.18_260_/_0.15),_transparent_70%)]" />
|
||||||
|
|
||||||
|
{/* Decorative floating rings */}
|
||||||
|
<div
|
||||||
|
className="absolute top-10 left-10 w-32 h-32 rounded-full border border-primary/10 cp-float hidden sm:block"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-10 right-10 w-24 h-24 rounded-full border border-accent-gradient/10 cp-float-delayed hidden sm:block"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 right-1/4 w-16 h-16 rounded-full bg-primary/5 blur-xl hidden md:block"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative max-w-2xl mx-auto text-center space-y-8">
|
||||||
|
<h2
|
||||||
|
className="text-display-md text-foreground animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
|
style={{ animationDelay: "0ms" }}
|
||||||
|
>
|
||||||
|
Ready to get connected?
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-lg text-muted-foreground max-w-lg mx-auto leading-relaxed animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
|
style={{ animationDelay: "100ms" }}
|
||||||
|
>
|
||||||
|
Contact us anytime — our bilingual team is here to help you find the right solution.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4 animate-in fade-in slide-in-from-bottom-6 duration-600"
|
||||||
|
style={{ animationDelay: "200ms" }}
|
||||||
|
>
|
||||||
|
<GlowButton href="/contact">
|
||||||
|
Contact Us
|
||||||
|
<ArrowRight className="h-5 w-5 ml-1" />
|
||||||
|
</GlowButton>
|
||||||
|
<GlowButton href="/services" variant="secondary">
|
||||||
|
Browse Services
|
||||||
|
</GlowButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,3 +4,4 @@ export { useMediaQuery, useIsMobile, useIsTablet, useIsDesktop } from "./useMedi
|
|||||||
export { useZodForm } from "./useZodForm";
|
export { useZodForm } from "./useZodForm";
|
||||||
export { useCurrency } from "./useCurrency";
|
export { useCurrency } from "./useCurrency";
|
||||||
export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency";
|
export { useFormatCurrency, type FormatCurrencyOptions } from "./useFormatCurrency";
|
||||||
|
export { useCountUp } from "./useCountUp";
|
||||||
|
|||||||
70
apps/portal/src/shared/hooks/useCountUp.ts
Normal file
70
apps/portal/src/shared/hooks/useCountUp.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseCountUpOptions {
|
||||||
|
/** Starting value (default: 0) */
|
||||||
|
start?: number;
|
||||||
|
/** Target value to count to */
|
||||||
|
end: number;
|
||||||
|
/** Animation duration in ms (default: 300) */
|
||||||
|
duration?: number;
|
||||||
|
/** Delay before starting animation in ms (default: 0) */
|
||||||
|
delay?: number;
|
||||||
|
/** Whether animation is enabled (default: true) */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated counter hook for stats and numbers
|
||||||
|
* Uses requestAnimationFrame for smooth 60fps animation
|
||||||
|
*/
|
||||||
|
export function useCountUp({
|
||||||
|
start = 0,
|
||||||
|
end,
|
||||||
|
duration = 300,
|
||||||
|
delay = 0,
|
||||||
|
enabled = true,
|
||||||
|
}: UseCountUpOptions): number {
|
||||||
|
const [count, setCount] = useState(start);
|
||||||
|
const frameRef = useRef<number | undefined>(undefined);
|
||||||
|
const startTimeRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If disabled or reduced motion preferred, show final value immediately
|
||||||
|
if (!enabled || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||||
|
setCount(end);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (!startTimeRef.current) {
|
||||||
|
startTimeRef.current = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.min((timestamp - startTimeRef.current) / duration, 1);
|
||||||
|
// Ease-out cubic for smooth deceleration
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = Math.round(start + (end - start) * eased);
|
||||||
|
|
||||||
|
setCount(current);
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
frameRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
frameRef.current = requestAnimationFrame(animate);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (frameRef.current) {
|
||||||
|
cancelAnimationFrame(frameRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [start, end, duration, delay, enabled]);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@ -61,17 +61,30 @@
|
|||||||
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 20–24px */
|
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 20–24px */
|
||||||
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 24–30px */
|
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 24–30px */
|
||||||
|
|
||||||
|
/* Display Typography (for headlines) */
|
||||||
|
--cp-text-display-sm: clamp(1.5rem, 1.3rem + 0.8vw, 1.875rem); /* 24-30px */
|
||||||
|
--cp-text-display-md: clamp(1.875rem, 1.5rem + 1.5vw, 2.5rem); /* 30-40px */
|
||||||
|
--cp-text-display-lg: clamp(2.5rem, 2rem + 2vw, 3.5rem); /* 40-56px */
|
||||||
|
--cp-text-display-xl: clamp(3rem, 2.5rem + 3vw, 4.5rem); /* 48-72px */
|
||||||
|
|
||||||
/* Weights */
|
/* Weights */
|
||||||
--cp-font-light: 300;
|
--cp-font-light: 300;
|
||||||
--cp-font-normal: 400;
|
--cp-font-normal: 400;
|
||||||
--cp-font-medium: 500;
|
--cp-font-medium: 500;
|
||||||
--cp-font-semibold: 600;
|
--cp-font-semibold: 600;
|
||||||
--cp-font-bold: 700;
|
--cp-font-bold: 700;
|
||||||
|
--cp-font-extrabold: 800;
|
||||||
|
|
||||||
/* Line heights */
|
/* Line heights */
|
||||||
--cp-leading-tight: 1.25;
|
--cp-leading-tight: 1.25;
|
||||||
--cp-leading-normal: 1.5;
|
--cp-leading-normal: 1.5;
|
||||||
--cp-leading-relaxed: 1.625;
|
--cp-leading-relaxed: 1.625;
|
||||||
|
--cp-leading-display: 1.1; /* Tighter for large display text */
|
||||||
|
|
||||||
|
/* Letter spacing */
|
||||||
|
--cp-tracking-tight: -0.02em;
|
||||||
|
--cp-tracking-normal: 0;
|
||||||
|
--cp-tracking-wide: 0.05em;
|
||||||
|
|
||||||
/* ============= RADII ============= */
|
/* ============= RADII ============= */
|
||||||
--cp-radius-sm: 0.375rem; /* 6px */
|
--cp-radius-sm: 0.375rem; /* 6px */
|
||||||
@ -94,10 +107,33 @@
|
|||||||
--cp-card-shadow-lg: var(--cp-shadow-3);
|
--cp-card-shadow-lg: var(--cp-shadow-3);
|
||||||
|
|
||||||
/* ============= MOTION ============= */
|
/* ============= MOTION ============= */
|
||||||
|
/* Durations */
|
||||||
--cp-duration-fast: 150ms;
|
--cp-duration-fast: 150ms;
|
||||||
--cp-duration-normal: 200ms;
|
--cp-duration-normal: 200ms;
|
||||||
--cp-duration-slow: 300ms;
|
--cp-duration-slow: 300ms;
|
||||||
|
--cp-duration-slower: 400ms;
|
||||||
|
--cp-duration-slowest: 500ms;
|
||||||
|
|
||||||
|
/* Easing functions */
|
||||||
--cp-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
--cp-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--cp-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--cp-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
--cp-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--cp-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--cp-ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||||
|
|
||||||
|
/* Stagger delays for orchestrated animations */
|
||||||
|
--cp-stagger-1: 50ms;
|
||||||
|
--cp-stagger-2: 100ms;
|
||||||
|
--cp-stagger-3: 150ms;
|
||||||
|
--cp-stagger-4: 200ms;
|
||||||
|
--cp-stagger-5: 250ms;
|
||||||
|
|
||||||
|
/* Transform distances */
|
||||||
|
--cp-translate-sm: 4px;
|
||||||
|
--cp-translate-md: 8px;
|
||||||
|
--cp-translate-lg: 16px;
|
||||||
|
--cp-translate-xl: 24px;
|
||||||
|
|
||||||
/* Aliases */
|
/* Aliases */
|
||||||
--cp-transition-fast: var(--cp-duration-fast);
|
--cp-transition-fast: var(--cp-duration-fast);
|
||||||
|
|||||||
@ -1,12 +1,478 @@
|
|||||||
/**
|
/**
|
||||||
* Design System Utilities
|
* Design System Utilities
|
||||||
*
|
*
|
||||||
* Semantic component primitives using the consolidated color system.
|
* Semantic component primitives using the consolidated color system.
|
||||||
* Layout, spacing, typography utilities come from Tailwind.
|
* Layout, spacing, typography utilities come from Tailwind.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ===== KEYFRAMES ===== */
|
||||||
|
|
||||||
|
@keyframes cp-fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(var(--cp-translate-lg));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-fade-scale {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-slide-in-left {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(calc(var(--cp-translate-xl) * -1));
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-toast-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-toast-exit {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
60% {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
40%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-activity-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) rotate(2deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-float-slow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-12px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 var(--primary);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 4px color-mix(in oklch, var(--primary) 40%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy shimmer animation for compatibility */
|
||||||
|
@keyframes cp-skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* ===== CARD ===== */
|
/* ===== DISPLAY TYPOGRAPHY ===== */
|
||||||
|
.font-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display-xl {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--cp-text-display-xl);
|
||||||
|
font-weight: var(--cp-font-extrabold);
|
||||||
|
line-height: var(--cp-leading-display);
|
||||||
|
letter-spacing: var(--cp-tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display-lg {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--cp-text-display-lg);
|
||||||
|
font-weight: var(--cp-font-bold);
|
||||||
|
line-height: var(--cp-leading-display);
|
||||||
|
letter-spacing: var(--cp-tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display-md {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--cp-text-display-md);
|
||||||
|
font-weight: var(--cp-font-semibold);
|
||||||
|
line-height: var(--cp-leading-tight);
|
||||||
|
letter-spacing: var(--cp-tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display-sm {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: var(--cp-text-display-sm);
|
||||||
|
font-weight: var(--cp-font-semibold);
|
||||||
|
line-height: var(--cp-leading-tight);
|
||||||
|
letter-spacing: var(--cp-tracking-tight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PAGE ENTRANCE ANIMATIONS ===== */
|
||||||
|
.cp-animate-in {
|
||||||
|
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-animate-scale-in {
|
||||||
|
animation: cp-fade-scale var(--cp-duration-normal) var(--cp-ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-animate-slide-left {
|
||||||
|
animation: cp-slide-in-left var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered children animation */
|
||||||
|
.cp-stagger-children > * {
|
||||||
|
opacity: 0;
|
||||||
|
animation: cp-fade-up var(--cp-duration-slow) var(--cp-ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-stagger-children > *:nth-child(1) {
|
||||||
|
animation-delay: var(--cp-stagger-1);
|
||||||
|
}
|
||||||
|
.cp-stagger-children > *:nth-child(2) {
|
||||||
|
animation-delay: var(--cp-stagger-2);
|
||||||
|
}
|
||||||
|
.cp-stagger-children > *:nth-child(3) {
|
||||||
|
animation-delay: var(--cp-stagger-3);
|
||||||
|
}
|
||||||
|
.cp-stagger-children > *:nth-child(4) {
|
||||||
|
animation-delay: var(--cp-stagger-4);
|
||||||
|
}
|
||||||
|
.cp-stagger-children > *:nth-child(5) {
|
||||||
|
animation-delay: var(--cp-stagger-5);
|
||||||
|
}
|
||||||
|
.cp-stagger-children > *:nth-child(n + 6) {
|
||||||
|
animation-delay: calc(var(--cp-stagger-5) + 50ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CARD HOVER LIFT ===== */
|
||||||
|
.cp-card-hover-lift {
|
||||||
|
transition:
|
||||||
|
transform var(--cp-duration-normal) var(--cp-ease-out),
|
||||||
|
box-shadow var(--cp-duration-normal) var(--cp-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-card-hover-lift:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 40px -10px rgb(0 0 0 / 0.15),
|
||||||
|
0 4px 6px -2px rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-card-hover-lift:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
transition-duration: var(--cp-duration-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SKELETON SHIMMER ===== */
|
||||||
|
.cp-skeleton-shimmer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--cp-skeleton-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-skeleton-shimmer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
var(--cp-skeleton-shimmer) 50%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: cp-shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INPUT FOCUS ANIMATIONS ===== */
|
||||||
|
.cp-input-focus {
|
||||||
|
transition:
|
||||||
|
border-color var(--cp-duration-fast) var(--cp-ease-out),
|
||||||
|
box-shadow var(--cp-duration-fast) var(--cp-ease-out),
|
||||||
|
background-color var(--cp-duration-fast) var(--cp-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input-focus:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-input-error-shake {
|
||||||
|
animation: cp-shake var(--cp-duration-slow) var(--cp-ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TOAST ANIMATIONS ===== */
|
||||||
|
.cp-toast-enter {
|
||||||
|
animation: cp-toast-enter var(--cp-duration-slow) var(--cp-ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-toast-exit {
|
||||||
|
animation: cp-toast-exit var(--cp-duration-normal) var(--cp-ease-in) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACTIVITY FEED ===== */
|
||||||
|
.cp-activity-item {
|
||||||
|
opacity: 0;
|
||||||
|
animation: cp-activity-enter var(--cp-duration-normal) var(--cp-ease-out) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-activity-item:nth-child(1) {
|
||||||
|
animation-delay: 0ms;
|
||||||
|
}
|
||||||
|
.cp-activity-item:nth-child(2) {
|
||||||
|
animation-delay: 50ms;
|
||||||
|
}
|
||||||
|
.cp-activity-item:nth-child(3) {
|
||||||
|
animation-delay: 100ms;
|
||||||
|
}
|
||||||
|
.cp-activity-item:nth-child(4) {
|
||||||
|
animation-delay: 150ms;
|
||||||
|
}
|
||||||
|
.cp-activity-item:nth-child(5) {
|
||||||
|
animation-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FLOAT ANIMATIONS ===== */
|
||||||
|
.cp-float {
|
||||||
|
animation: cp-float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-float-slow {
|
||||||
|
animation: cp-float-slow 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-float-delayed {
|
||||||
|
animation: cp-float 6s ease-in-out infinite 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GLASS MORPHISM ===== */
|
||||||
|
.cp-glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-glass-strong {
|
||||||
|
background: var(--glass-bg-strong);
|
||||||
|
backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-glass-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--cp-card-radius);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px -4px oklch(0 0 0 / 0.08),
|
||||||
|
inset 0 1px 0 oklch(1 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRADIENTS ===== */
|
||||||
|
.cp-gradient-primary {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-premium {
|
||||||
|
background: var(--gradient-premium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-subtle {
|
||||||
|
background: var(--gradient-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-text {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-glow {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-glow::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(12px);
|
||||||
|
transition: opacity var(--cp-duration-slow) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-glow:hover::before {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PREMIUM BUTTONS ===== */
|
||||||
|
.cp-btn-premium {
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
box-shadow: var(--shadow-primary-sm);
|
||||||
|
transition: all var(--cp-duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn-premium:hover {
|
||||||
|
box-shadow: var(--shadow-primary-md);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-btn-premium:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: var(--shadow-primary-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GLOW EFFECTS ===== */
|
||||||
|
.cp-glow {
|
||||||
|
box-shadow: var(--shadow-primary-sm);
|
||||||
|
transition: box-shadow var(--cp-duration-slow) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-glow:hover {
|
||||||
|
box-shadow: var(--shadow-primary-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-glow-pulse {
|
||||||
|
animation: cp-pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PREMIUM CARD VARIANTS ===== */
|
||||||
|
.cp-card-glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--cp-card-radius);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px -4px oklch(0 0 0 / 0.06),
|
||||||
|
inset 0 1px 0 oklch(1 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-card-gradient {
|
||||||
|
background: var(--gradient-subtle);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--cp-card-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-card-premium {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid color-mix(in oklch, var(--primary) 20%, transparent);
|
||||||
|
border-radius: var(--cp-card-radius);
|
||||||
|
box-shadow:
|
||||||
|
0 4px 24px -4px color-mix(in oklch, var(--primary) 8%, transparent),
|
||||||
|
inset 0 1px 0 oklch(1 0 0 / 0.05);
|
||||||
|
transition: all var(--cp-duration-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-card-premium:hover {
|
||||||
|
border-color: color-mix(in oklch, var(--primary) 40%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px -8px color-mix(in oklch, var(--primary) 15%, transparent),
|
||||||
|
inset 0 1px 0 oklch(1 0 0 / 0.05);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRADIENT BORDER ===== */
|
||||||
|
.cp-gradient-border {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-border::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--gradient-primary);
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#fff 0 0) content-box,
|
||||||
|
linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--cp-duration-slow) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cp-gradient-border:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CARD (legacy) ===== */
|
||||||
.cp-card {
|
.cp-card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
color: var(--card-foreground);
|
color: var(--card-foreground);
|
||||||
@ -15,8 +481,8 @@
|
|||||||
box-shadow: var(--cp-card-shadow);
|
box-shadow: var(--cp-card-shadow);
|
||||||
padding: var(--cp-card-padding);
|
padding: var(--cp-card-padding);
|
||||||
transition:
|
transition:
|
||||||
box-shadow 0.2s ease,
|
box-shadow var(--cp-duration-normal) ease,
|
||||||
border-color 0.2s ease;
|
border-color var(--cp-duration-normal) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cp-card:hover {
|
.cp-card:hover {
|
||||||
@ -141,15 +607,6 @@
|
|||||||
animation: cp-skeleton-shimmer 2s infinite;
|
animation: cp-skeleton-shimmer 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cp-skeleton-shimmer {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== FOCUS RING ===== */
|
/* ===== FOCUS RING ===== */
|
||||||
.cp-focus-ring {
|
.cp-focus-ring {
|
||||||
outline: var(--cp-focus-ring);
|
outline: var(--cp-focus-ring);
|
||||||
@ -160,4 +617,24 @@
|
|||||||
outline: var(--cp-focus-ring);
|
outline: var(--cp-focus-ring);
|
||||||
outline-offset: var(--cp-focus-ring-offset);
|
outline-offset: var(--cp-focus-ring-offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY: REDUCED MOTION ===== */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cp-animate-in,
|
||||||
|
.cp-animate-scale-in,
|
||||||
|
.cp-animate-slide-left,
|
||||||
|
.cp-stagger-children > *,
|
||||||
|
.cp-card-hover-lift,
|
||||||
|
.cp-toast-enter,
|
||||||
|
.cp-toast-exit,
|
||||||
|
.cp-activity-item,
|
||||||
|
.cp-float,
|
||||||
|
.cp-float-slow,
|
||||||
|
.cp-float-delayed,
|
||||||
|
.cp-glow-pulse {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,3 +25,13 @@ export {
|
|||||||
cancellationStatusSchema,
|
cancellationStatusSchema,
|
||||||
cancellationPreviewSchema,
|
cancellationPreviewSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
export {
|
||||||
|
generateCancellationMonths,
|
||||||
|
getCancellationEffectiveDate,
|
||||||
|
getRunDateFromMonth,
|
||||||
|
type BaseCancellationMonth,
|
||||||
|
type CancellationMonthWithRunDate,
|
||||||
|
type GenerateCancellationMonthsOptions,
|
||||||
|
} from "./utils/index.js";
|
||||||
|
|||||||
155
packages/domain/subscriptions/utils/cancellation-months.ts
Normal file
155
packages/domain/subscriptions/utils/cancellation-months.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Cancellation Months Utility
|
||||||
|
*
|
||||||
|
* Generates available cancellation months following the "25th rule":
|
||||||
|
* - If current day is before or on the 25th, current month is available
|
||||||
|
* - If current day is after the 25th, only next month onwards is available
|
||||||
|
*
|
||||||
|
* Used by both SIM and Internet cancellation flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base cancellation month (value + label)
|
||||||
|
*/
|
||||||
|
export interface BaseCancellationMonth {
|
||||||
|
/** Month in YYYY-MM format */
|
||||||
|
value: string;
|
||||||
|
/** Human-readable label (e.g., "January 2025") */
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended cancellation month with runDate (for SIM/Freebit API)
|
||||||
|
*/
|
||||||
|
export interface CancellationMonthWithRunDate extends BaseCancellationMonth {
|
||||||
|
/** Freebit API run date in YYYYMMDD format (1st of next month) */
|
||||||
|
runDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for generating cancellation months
|
||||||
|
*/
|
||||||
|
export interface GenerateCancellationMonthsOptions {
|
||||||
|
/** Cutoff day of month (default: 25) */
|
||||||
|
cutoffDay?: number;
|
||||||
|
/** Number of months to generate (default: 12) */
|
||||||
|
monthCount?: number;
|
||||||
|
/** Whether to include runDate for Freebit API (default: false) */
|
||||||
|
includeRunDate?: boolean;
|
||||||
|
/** Override "today" for testing */
|
||||||
|
referenceDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate available cancellation months following the 25th rule.
|
||||||
|
*
|
||||||
|
* The 25th rule:
|
||||||
|
* - If submitting before or on the 25th, current month is available for cancellation
|
||||||
|
* - If submitting after the 25th, only next month onwards is available
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Basic usage (Internet cancellation)
|
||||||
|
* const months = generateCancellationMonths();
|
||||||
|
* // Returns: [{ value: "2025-01", label: "January 2025" }, ...]
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // With runDate for SIM/Freebit API
|
||||||
|
* const months = generateCancellationMonths({ includeRunDate: true });
|
||||||
|
* // Returns: [{ value: "2025-01", label: "January 2025", runDate: "20250201" }, ...]
|
||||||
|
*/
|
||||||
|
export function generateCancellationMonths(
|
||||||
|
options?: GenerateCancellationMonthsOptions & { includeRunDate?: false }
|
||||||
|
): BaseCancellationMonth[];
|
||||||
|
export function generateCancellationMonths(
|
||||||
|
options: GenerateCancellationMonthsOptions & { includeRunDate: true }
|
||||||
|
): CancellationMonthWithRunDate[];
|
||||||
|
export function generateCancellationMonths(
|
||||||
|
options: GenerateCancellationMonthsOptions = {}
|
||||||
|
): BaseCancellationMonth[] | CancellationMonthWithRunDate[] {
|
||||||
|
const {
|
||||||
|
cutoffDay = 25,
|
||||||
|
monthCount = 12,
|
||||||
|
includeRunDate = false,
|
||||||
|
referenceDate = new Date(),
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const months: (BaseCancellationMonth | CancellationMonthWithRunDate)[] = [];
|
||||||
|
const dayOfMonth = referenceDate.getDate();
|
||||||
|
|
||||||
|
// Start from current month if before/on cutoff, otherwise next month
|
||||||
|
const startOffset = dayOfMonth <= cutoffDay ? 0 : 1;
|
||||||
|
|
||||||
|
for (let i = startOffset; i < startOffset + monthCount; i++) {
|
||||||
|
const date = new Date(referenceDate.getFullYear(), referenceDate.getMonth() + i, 1);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const monthStr = String(month).padStart(2, "0");
|
||||||
|
|
||||||
|
const monthEntry: BaseCancellationMonth = {
|
||||||
|
value: `${year}-${monthStr}`,
|
||||||
|
label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (includeRunDate) {
|
||||||
|
// runDate is the 1st of the NEXT month (cancellation takes effect at month end)
|
||||||
|
const nextMonth = new Date(year, month, 1);
|
||||||
|
const runYear = nextMonth.getFullYear();
|
||||||
|
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
|
(monthEntry as CancellationMonthWithRunDate).runDate = `${runYear}${runMonth}01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
months.push(monthEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return months;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the last day of a month for cancellation effective date.
|
||||||
|
*
|
||||||
|
* @param yearMonth - Month in YYYY-MM format
|
||||||
|
* @returns Date string in YYYY-MM-DD format (last day of month)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getCancellationEffectiveDate("2025-01");
|
||||||
|
* // Returns: "2025-01-31"
|
||||||
|
*/
|
||||||
|
export function getCancellationEffectiveDate(yearMonth: string): string {
|
||||||
|
const [year, month] = yearMonth.split("-").map(Number);
|
||||||
|
if (!year || !month) {
|
||||||
|
throw new Error(`Invalid year-month format: ${yearMonth}. Expected YYYY-MM`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// month is 1-indexed, so new Date(year, month, 0) gives last day of that month
|
||||||
|
const lastDayOfMonth = new Date(year, month, 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
lastDayOfMonth.getFullYear(),
|
||||||
|
String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(lastDayOfMonth.getDate()).padStart(2, "0"),
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the Freebit API runDate from a cancellation month.
|
||||||
|
*
|
||||||
|
* @param yearMonth - Month in YYYY-MM format
|
||||||
|
* @returns Date string in YYYYMMDD format (1st of next month)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getRunDateFromMonth("2025-01");
|
||||||
|
* // Returns: "20250201"
|
||||||
|
*/
|
||||||
|
export function getRunDateFromMonth(yearMonth: string): string {
|
||||||
|
const [year, month] = yearMonth.split("-").map(Number);
|
||||||
|
if (!year || !month) {
|
||||||
|
throw new Error(`Invalid year-month format: ${yearMonth}. Expected YYYY-MM`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = new Date(year, month, 1);
|
||||||
|
const runYear = nextMonth.getFullYear();
|
||||||
|
const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${runYear}${runMonth}01`;
|
||||||
|
}
|
||||||
14
packages/domain/subscriptions/utils/index.ts
Normal file
14
packages/domain/subscriptions/utils/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Subscription Utilities
|
||||||
|
*
|
||||||
|
* Shared utility functions for subscription management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateCancellationMonths,
|
||||||
|
getCancellationEffectiveDate,
|
||||||
|
getRunDateFromMonth,
|
||||||
|
type BaseCancellationMonth,
|
||||||
|
type CancellationMonthWithRunDate,
|
||||||
|
type GenerateCancellationMonthsOptions,
|
||||||
|
} from "./cancellation-months.js";
|
||||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -201,6 +201,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
geist:
|
||||||
|
specifier: ^1.5.1
|
||||||
|
version: 1.5.1(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
@ -4433,6 +4436,14 @@ packages:
|
|||||||
integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==,
|
integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
geist@1.5.1:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==,
|
||||||
|
}
|
||||||
|
peerDependencies:
|
||||||
|
next: ">=13.2.0"
|
||||||
|
|
||||||
generate-function@2.3.1:
|
generate-function@2.3.1:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@ -10118,6 +10129,10 @@ snapshots:
|
|||||||
|
|
||||||
function-bind@1.1.2: {}
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
|
geist@1.5.1(next@16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)):
|
||||||
|
dependencies:
|
||||||
|
next: 16.1.1(@babel/core@7.28.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
|
||||||
generate-function@2.3.1:
|
generate-function@2.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-property: 1.0.2
|
is-property: 1.0.2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user