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:
barsa 2026-01-13 16:19:39 +09:00
parent 4dd4278677
commit bde9f706ce
75 changed files with 3341 additions and 1256 deletions

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

View 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;
}

View 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;
});
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
) {} ) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ( or )
*/
@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, "");
}
}

View File

@ -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 ( or )
*/
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}`;
}
} }

View File

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

View File

@ -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
) {} ) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export { GlowButton } from "./GlowButton";
export { ValuePropCard } from "./ValuePropCard";
export { BentoServiceCard } from "./BentoServiceCard";
export { FloatingGlassCard } from "./FloatingGlassCard";
export { AnimatedBackground } from "./AnimatedBackground";

View File

@ -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&apos;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&apos;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>

View File

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

View 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;
}

View File

@ -61,17 +61,30 @@
--cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 2024px */ --cp-text-2xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem); /* 2024px */
--cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 2430px */ --cp-text-3xl: clamp(1.5rem, 1.2rem + 1.2vw, 1.875rem); /* 2430px */
/* 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);

View File

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

View File

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

View 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`;
}

View 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
View File

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