Merge pull request #1 from NTumurbars/Tema-v1

Tema v1
This commit is contained in:
NTumurbars 2025-08-30 15:51:56 +09:00 committed by GitHub
commit 9a7c1a06bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2481 additions and 919 deletions

View File

@ -10,6 +10,7 @@ import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { SetPasswordDto } from "./dto/set-password.dto"; import { SetPasswordDto } from "./dto/set-password.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { Public } from "./decorators/public.decorator"; import { Public } from "./decorators/public.decorator";
@ApiTags("auth") @ApiTags("auth")
@ -17,6 +18,27 @@ import { Public } from "./decorators/public.decorator";
export class AuthController { export class AuthController {
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@Public()
@Post("validate-signup")
@UseGuards(AuthThrottleGuard)
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP
@ApiOperation({ summary: "Validate customer number for signup" })
@ApiResponse({ status: 200, description: "Validation successful" })
@ApiResponse({ status: 409, description: "Customer already has account" })
@ApiResponse({ status: 400, description: "Customer number not found" })
@ApiResponse({ status: 429, description: "Too many validation attempts" })
async validateSignup(@Body() validateDto: ValidateSignupDto, @Req() req: Request) {
return this.authService.validateSignup(validateDto, req);
}
@Public()
@Get("health-check")
@ApiOperation({ summary: "Check auth service health and integrations" })
@ApiResponse({ status: 200, description: "Health check results" })
async healthCheck() {
return this.authService.healthCheck();
}
@Public() @Public()
@Post("signup") @Post("signup")
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)

View File

@ -16,6 +16,7 @@ import { AuditService, AuditAction } from "../common/audit/audit.service";
import { TokenBlacklistService } from "./services/token-blacklist.service"; import { TokenBlacklistService } from "./services/token-blacklist.service";
import { SignupDto } from "./dto/signup.dto"; import { SignupDto } from "./dto/signup.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { SetPasswordDto } from "./dto/set-password.dto"; import { SetPasswordDto } from "./dto/set-password.dto";
import { getErrorMessage } from "../common/utils/error.util"; import { getErrorMessage } from "../common/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -43,6 +44,134 @@ export class AuthService {
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
async healthCheck() {
const health = {
database: false,
whmcs: false,
salesforce: false,
whmcsConfig: {
baseUrl: !!this.configService.get("WHMCS_BASE_URL"),
identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"),
secret: !!this.configService.get("WHMCS_API_SECRET"),
},
salesforceConfig: {
connected: false,
},
};
// Check database
try {
await this.usersService.findByEmail("health-check@test.com");
health.database = true;
} catch (error) {
this.logger.debug("Database health check failed", { error: getErrorMessage(error) });
}
// Check WHMCS
try {
// Try a simple WHMCS API call (this will fail if not configured)
await this.whmcsService.getProducts();
health.whmcs = true;
} catch (error) {
this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) });
}
// Check Salesforce
try {
health.salesforceConfig.connected = this.salesforceService.healthCheck();
health.salesforce = health.salesforceConfig.connected;
} catch (error) {
this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) });
}
return {
status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded",
services: health,
timestamp: new Date().toISOString(),
};
}
async validateSignup(validateData: ValidateSignupDto, request?: Request) {
const { sfNumber } = validateData;
try {
// 1. Check if SF number exists in Salesforce
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
if (!sfAccount) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, reason: "SF number not found" },
request,
false,
"Customer number not found in Salesforce"
);
throw new BadRequestException("Customer number not found in Salesforce");
}
// 2. Check if SF account already has a mapping (already registered)
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
if (existingMapping) {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
request,
false,
"Customer number already registered"
);
throw new ConflictException("You already have an account. Please use the login page to access your existing account.");
}
// 3. Check WH_Account__c field in Salesforce
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: sfAccount.id, whAccount: accountDetails.WH_Account__c, reason: "WH Account not empty" },
request,
false,
"Account already has WHMCS integration"
);
throw new ConflictException("You already have an account. Please use the login page to access your existing account.");
}
// Log successful validation
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
request,
true
);
return {
valid: true,
sfAccountId: sfAccount.id,
message: "Customer number validated successfully"
};
} catch (error) {
// Re-throw known exceptions
if (error instanceof BadRequestException || error instanceof ConflictException) {
throw error;
}
// Log unexpected errors
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
undefined,
{ sfNumber, error: getErrorMessage(error) },
request,
false,
getErrorMessage(error)
);
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
throw new BadRequestException("Validation failed");
}
}
async signup(signupData: SignupDto, request?: Request) { async signup(signupData: SignupDto, request?: Request) {
const { const {
email, email,
@ -106,36 +235,91 @@ export class AuthService {
}); });
// 2. Create client in WHMCS // 2. Create client in WHMCS
// Prepare WHMCS custom fields (IDs configurable via env) let whmcsClient: { clientId: number };
const customerNumberFieldId = this.configService.get<string>( try {
"WHMCS_CUSTOMER_NUMBER_FIELD_ID", // Prepare WHMCS custom fields (IDs configurable via env)
"198" const customerNumberFieldId = this.configService.get<string>(
); "WHMCS_CUSTOMER_NUMBER_FIELD_ID",
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID"); "198"
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID"); );
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID"); const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
const customfields: Record<string, string> = {}; const customfields: Record<string, string> = {};
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber; if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth; if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfields[genderFieldId] = gender; if (genderFieldId && gender) customfields[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality; if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
const whmcsClient: { clientId: number } = await this.whmcsService.addClient({ this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
firstname: firstName,
lastname: lastName, // Validate required WHMCS fields
email, if (!address?.line1 || !address?.city || !address?.state || !address?.postalCode || !address?.country) {
companyname: company || "", throw new BadRequestException("Complete address information is required for billing account creation");
phonenumber: phone || "", }
address1: address.line1,
address2: address.line2 || "", if (!phone) {
city: address.city, throw new BadRequestException("Phone number is required for billing account creation");
state: address.state, }
postcode: address.postalCode,
country: address.country, this.logger.log("WHMCS client data", {
password2: password, // WHMCS requires plain password for new clients email,
customfields, firstName,
}); lastName,
address: address,
phone,
country: address.country,
});
whmcsClient = await this.whmcsService.addClient({
firstname: firstName,
lastname: lastName,
email,
companyname: company || "",
phonenumber: phone,
address1: address.line1,
address2: address.line2 || "",
city: address.city,
state: address.state,
postcode: address.postalCode,
country: address.country,
password2: password, // WHMCS requires plain password for new clients
customfields,
});
this.logger.log("WHMCS client created successfully", {
clientId: whmcsClient.clientId,
email
});
} catch (whmcsError) {
this.logger.error("Failed to create WHMCS client", {
error: getErrorMessage(whmcsError),
email,
firstName,
lastName,
});
// Rollback: Delete the portal user since WHMCS creation failed
try {
// Note: We should add a delete method to UsersService, but for now use direct approach
this.logger.warn("WHMCS creation failed, user account created but not fully integrated", {
userId: user.id,
email,
whmcsError: getErrorMessage(whmcsError),
});
} catch (rollbackError) {
this.logger.error("Failed to log rollback information", {
userId: user.id,
email,
rollbackError: getErrorMessage(rollbackError),
});
}
throw new BadRequestException(
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
);
}
// 3. Store ID mappings // 3. Store ID mappings
await this.mappingsService.createMapping({ await this.mappingsService.createMapping({
@ -144,11 +328,15 @@ export class AuthService {
sfAccountId: sfAccount.id, sfAccountId: sfAccount.id,
}); });
// 4. Update WH_Account__c field in Salesforce
const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`;
await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue);
// Log successful signup // Log successful signup
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
user.id, user.id,
{ email, whmcsClientId: whmcsClient.clientId }, { email, whmcsClientId: whmcsClient.clientId, whAccountValue },
request, request,
true true
); );

View File

@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class ValidateSignupDto {
@ApiProperty({
description: "Customer Number (SF Number) to validate",
example: "12345",
})
@IsString()
@IsNotEmpty()
sfNumber: string;
}

View File

@ -231,31 +231,36 @@ export class InternetCatalogService extends BaseCatalogService {
{ description: string; tierDescription: string; features: string[] } { description: string; tierDescription: string; features: string[] }
> = { > = {
Silver: { Silver: {
description: "Basic setup - bring your own router", description: "Simple package with broadband-modem and ISP only",
tierDescription: "Basic", tierDescription: "Simple package with broadband-modem and ISP only",
features: [ features: [
"1 NTT Modem (router not included)", "NTT modem + ISP connection",
"1 SonixNet ISP (IPoE-BYOR or PPPoE) Activation + Monthly", "Two ISP connection protocols: IPoE (recommended) or PPPoE",
"Customer setup required", "Self-configuration of router (you provide your own)",
"Monthly: ¥6,000 | One-time: ¥22,800",
], ],
}, },
Gold: { Gold: {
description: "Complete solution with v6plus router included", description: "Standard all-inclusive package with basic Wi-Fi",
tierDescription: "Recommended", tierDescription: "Standard all-inclusive package with basic Wi-Fi",
features: [ features: [
"1 NTT Wireless Home Gateway Router (v6plus compatible)", "NTT modem + wireless router (rental)",
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly", "ISP (IPoE) configured automatically within 24 hours",
"Professional setup included", "Basic wireless router included",
"Optional: TP-LINK RE650 range extender (¥500/month)",
"Monthly: ¥6,500 | One-time: ¥22,800",
], ],
}, },
Platinum: { Platinum: {
description: "Premium management for residences >50㎡", description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
tierDescription: "Premium", tierDescription: "Tailored set up with premier Wi-Fi management support",
features: [ features: [
"1 NTT Wireless Home Gateway Router Rental", "NTT modem + Netgear INSIGHT Wi-Fi routers",
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly", "Cloud management support for remote router management",
"NETGEAR INSIGHT Cloud Management System (¥500/month per router)", "Automatic updates and quicker support",
"Professional WiFi setup consultation and additional router recommendations", "Seamless wireless network setup",
"Monthly: ¥6,500 | One-time: ¥22,800",
"Cloud management: ¥500/month per router",
], ],
}, },
}; };

View File

@ -100,6 +100,63 @@ export class InvoicesController {
}); });
} }
@Get("payment-methods")
@ApiOperation({
summary: "Get user payment methods",
description: "Retrieves all saved payment methods for the authenticated user",
})
@ApiResponse({
status: 200,
description: "List of payment methods",
type: Object, // Would be PaymentMethodList if we had proper DTO decorators
})
async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
return this.invoicesService.getPaymentMethods(req.user.id);
}
@Get("payment-gateways")
@ApiOperation({
summary: "Get available payment gateways",
description: "Retrieves all active payment gateways available for payments",
})
@ApiResponse({
status: 200,
description: "List of payment gateways",
type: Object, // Would be PaymentGatewayList if we had proper DTO decorators
})
async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.invoicesService.getPaymentGateways();
}
@Get("test-payment-methods/:clientId")
@ApiOperation({
summary: "Test WHMCS payment methods API for specific client ID",
description: "Direct test of WHMCS GetPayMethods API - TEMPORARY DEBUG ENDPOINT",
})
@ApiParam({ name: "clientId", type: Number, description: "WHMCS Client ID to test" })
async testPaymentMethods(@Param("clientId", ParseIntPipe) clientId: number): Promise<any> {
return this.invoicesService.testWhmcsPaymentMethods(clientId);
}
@Post("payment-methods/refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh payment methods cache",
description: "Invalidates and refreshes payment methods cache for the current user",
})
@ApiResponse({
status: 200,
description: "Payment methods cache refreshed",
type: Object,
})
async refreshPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
// Invalidate cache first
await this.invoicesService.invalidatePaymentMethodsCache(req.user.id);
// Return fresh payment methods
return this.invoicesService.getPaymentMethods(req.user.id);
}
@Get(":id") @Get(":id")
@ApiOperation({ @ApiOperation({
summary: "Get invoice details by ID", summary: "Get invoice details by ID",
@ -182,34 +239,6 @@ export class InvoicesController {
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view"); return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view");
} }
@Get("payment-methods")
@ApiOperation({
summary: "Get user payment methods",
description: "Retrieves all saved payment methods for the authenticated user",
})
@ApiResponse({
status: 200,
description: "List of payment methods",
type: Object, // Would be PaymentMethodList if we had proper DTO decorators
})
async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
return this.invoicesService.getPaymentMethods(req.user.id);
}
@Get("payment-gateways")
@ApiOperation({
summary: "Get available payment gateways",
description: "Retrieves all active payment gateways available for payments",
})
@ApiResponse({
status: 200,
description: "List of payment gateways",
type: Object, // Would be PaymentGatewayList if we had proper DTO decorators
})
async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.invoicesService.getPaymentGateways();
}
@Post(":id/payment-link") @Post(":id/payment-link")
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ @ApiOperation({

View File

@ -270,7 +270,7 @@ export class InvoicesService {
overdue: 0, overdue: 0,
totalAmount: 0, totalAmount: 0,
unpaidAmount: 0, unpaidAmount: 0,
currency: "USD", currency: "JPY",
}; };
} }
@ -284,7 +284,7 @@ export class InvoicesService {
unpaidAmount: invoices unpaidAmount: invoices
.filter(i => ["Unpaid", "Overdue"].includes(i.status)) .filter(i => ["Unpaid", "Overdue"].includes(i.status))
.reduce((sum, i) => sum + i.total, 0), .reduce((sum, i) => sum + i.total, 0),
currency: invoices[0]?.currency || "USD", currency: invoices[0]?.currency || "JPY",
}; };
this.logger.log(`Generated invoice stats for user ${userId}`, stats); this.logger.log(`Generated invoice stats for user ${userId}`, stats);
@ -387,12 +387,17 @@ export class InvoicesService {
*/ */
async getPaymentMethods(userId: string): Promise<PaymentMethodList> { async getPaymentMethods(userId: string): Promise<PaymentMethodList> {
try { try {
this.logger.log(`Starting payment methods retrieval for user ${userId}`);
// Get WHMCS client ID from user mapping // Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
this.logger.error(`No WHMCS client mapping found for user ${userId}`);
throw new NotFoundException("WHMCS client mapping not found"); throw new NotFoundException("WHMCS client mapping not found");
} }
this.logger.log(`Found WHMCS client ID ${mapping.whmcsClientId} for user ${userId}`);
// Fetch payment methods from WHMCS // Fetch payment methods from WHMCS
const paymentMethods = await this.whmcsService.getPaymentMethods( const paymentMethods = await this.whmcsService.getPaymentMethods(
mapping.whmcsClientId, mapping.whmcsClientId,
@ -400,12 +405,15 @@ export class InvoicesService {
); );
this.logger.log( this.logger.log(
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId}` `Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId} (client ${mapping.whmcsClientId})`
); );
return paymentMethods; return paymentMethods;
} catch (error) { } catch (error) {
this.logger.error(`Failed to get payment methods for user ${userId}`, { this.logger.error(`Failed to get payment methods for user ${userId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
errorType: error instanceof Error ? error.constructor.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
}); });
if (error instanceof NotFoundException) { if (error instanceof NotFoundException) {
@ -416,6 +424,29 @@ export class InvoicesService {
} }
} }
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
try {
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Invalidate WHMCS payment methods cache
await this.whmcsService.invalidatePaymentMethodsCache(userId);
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
error: getErrorMessage(error),
});
throw new Error(`Failed to invalidate payment methods cache: ${getErrorMessage(error)}`);
}
}
/** /**
* Get available payment gateways * Get available payment gateways
*/ */
@ -435,6 +466,45 @@ export class InvoicesService {
} }
} }
/**
* TEMPORARY DEBUG METHOD: Test WHMCS payment methods API directly
*/
async testWhmcsPaymentMethods(clientId: number): Promise<any> {
try {
this.logger.log(`🔬 TESTING WHMCS GetPayMethods API for client ${clientId}`);
// Call WHMCS API directly with detailed logging
const result = await this.whmcsService.getPaymentMethods(clientId, `test-client-${clientId}`);
this.logger.log(`🔬 Test result for client ${clientId}:`, {
totalCount: result.totalCount,
paymentMethods: result.paymentMethods,
});
return {
clientId,
testTimestamp: new Date().toISOString(),
whmcsResponse: result,
summary: {
totalPaymentMethods: result.totalCount,
hasPaymentMethods: result.totalCount > 0,
paymentMethodTypes: result.paymentMethods.map(pm => pm.type),
}
};
} catch (error) {
this.logger.error(`🔬 Test failed for client ${clientId}`, {
error: getErrorMessage(error),
});
return {
clientId,
testTimestamp: new Date().toISOString(),
error: getErrorMessage(error),
errorType: error instanceof Error ? error.constructor.name : typeof error,
};
}
}
/** /**
* Create payment SSO link for invoice with specific payment method or gateway * Create payment SSO link for invoice with specific payment method or gateway
*/ */

View File

@ -89,6 +89,59 @@ export class MappingsService {
} }
} }
/**
* Find mapping by Salesforce Account ID
*/
async findBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
try {
// Validate SF Account ID
if (!sfAccountId) {
throw new BadRequestException("Salesforce Account ID is required");
}
// Try cache first (check all cached mappings)
const allCached = await this.getAllMappingsFromDb();
const cachedMapping = allCached.find((m: UserIdMapping) => m.sfAccountId === sfAccountId);
if (cachedMapping) {
this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);
return cachedMapping;
}
// Fetch from database
const dbMapping = await this.prisma.idMapping.findFirst({
where: { sfAccountId },
});
if (!dbMapping) {
this.logger.debug(`No mapping found for SF account ${sfAccountId}`);
return null;
}
const mapping: UserIdMapping = {
userId: dbMapping.userId,
whmcsClientId: dbMapping.whmcsClientId,
sfAccountId: dbMapping.sfAccountId || undefined,
createdAt: dbMapping.createdAt,
updatedAt: dbMapping.updatedAt,
};
// Cache the result
await this.cacheService.setMapping(mapping);
this.logger.debug(`Found mapping for SF account ${sfAccountId}`, {
userId: mapping.userId,
whmcsClientId: mapping.whmcsClientId,
});
return mapping;
} catch (error) {
this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/** /**
* Find mapping by user ID * Find mapping by user ID
*/ */
@ -193,57 +246,7 @@ export class MappingsService {
} }
} }
/**
* Find mapping by Salesforce account ID
*/
async findBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
try {
// Validate Salesforce account ID
if (!sfAccountId) {
throw new BadRequestException("Salesforce account ID is required");
}
// Try cache first
const cached = await this.cacheService.getBySfAccountId(sfAccountId);
if (cached) {
this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);
return cached;
}
// Fetch from database
const dbMapping = await this.prisma.idMapping.findFirst({
where: { sfAccountId },
});
if (!dbMapping) {
this.logger.debug(`No mapping found for SF account ${sfAccountId}`);
return null;
}
const mapping: UserIdMapping = {
userId: dbMapping.userId,
whmcsClientId: dbMapping.whmcsClientId,
sfAccountId: dbMapping.sfAccountId || undefined,
createdAt: dbMapping.createdAt,
updatedAt: dbMapping.updatedAt,
};
// Cache the result
await this.cacheService.setMapping(mapping);
this.logger.debug(`Found mapping for SF account ${sfAccountId}`, {
userId: mapping.userId,
whmcsClientId: mapping.whmcsClientId,
});
return mapping;
} catch (error) {
this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/** /**
* Update an existing mapping * Update an existing mapping

View File

@ -218,7 +218,7 @@ export class SubscriptionsService {
completed: subscriptions.filter(s => s.status === "Completed").length, completed: subscriptions.filter(s => s.status === "Completed").length,
totalMonthlyRevenue, totalMonthlyRevenue,
activeMonthlyRevenue, activeMonthlyRevenue,
currency: subscriptions[0]?.currency || "USD", currency: subscriptions[0]?.currency || "JPY",
}; };
this.logger.log(`Generated subscription stats for user ${userId}`, { this.logger.log(`Generated subscription stats for user ${userId}`, {

View File

@ -62,6 +62,14 @@ export class SalesforceService implements OnModuleInit {
return this.accountService.findByCustomerNumber(customerNumber); return this.accountService.findByCustomerNumber(customerNumber);
} }
async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> {
return this.accountService.getAccountDetails(accountId);
}
async updateWhAccount(accountId: string, whAccountValue: string): Promise<void> {
return this.accountService.updateWhAccount(accountId, whAccountValue);
}
async upsertAccount(accountData: AccountData): Promise<UpsertResult> { async upsertAccount(accountData: AccountData): Promise<UpsertResult> {
return this.accountService.upsert(accountData); return this.accountService.upsert(accountData);
} }

View File

@ -28,6 +28,7 @@ interface SalesforceQueryResult {
interface SalesforceAccount { interface SalesforceAccount {
Id: string; Id: string;
Name: string; Name: string;
WH_Account__c?: string;
} }
interface SalesforceCreateResult { interface SalesforceCreateResult {
@ -58,6 +59,61 @@ export class SalesforceAccountService {
} }
} }
async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> {
if (!accountId?.trim()) throw new Error("Account ID is required");
try {
const result = (await this.connection.query(
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
)) as SalesforceQueryResult;
if (result.totalSize === 0) {
return null;
}
const record = result.records[0];
return {
id: record.Id,
Name: record.Name,
WH_Account__c: record.WH_Account__c || undefined,
};
} catch (error) {
this.logger.error("Failed to get account details", {
accountId,
error: getErrorMessage(error),
});
throw new Error("Failed to get account details");
}
}
async updateWhAccount(accountId: string, whAccountValue: string): Promise<void> {
if (!accountId?.trim()) throw new Error("Account ID is required");
if (!whAccountValue?.trim()) throw new Error("WH Account value is required");
try {
const sobject = this.connection.sobject("Account") as unknown as {
update: (data: Record<string, unknown>) => Promise<unknown>;
};
await sobject.update({
Id: accountId.trim(),
WH_Account__c: whAccountValue.trim(),
});
this.logger.log("Updated WH Account field", {
accountId,
whAccountValue,
});
} catch (error) {
this.logger.error("Failed to update WH Account field", {
accountId,
whAccountValue,
error: getErrorMessage(error),
});
throw new Error("Failed to update WH Account field");
}
}
async upsert(accountData: AccountData): Promise<UpsertResult> { async upsert(accountData: AccountData): Promise<UpsertResult> {
if (!accountData.name?.trim()) throw new Error("Account name is required"); if (!accountData.name?.trim()) throw new Error("Account name is required");

View File

@ -21,21 +21,103 @@ export class WhmcsPaymentService {
*/ */
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> { async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
try { try {
// Try cache first // TEMPORARILY BYPASS CACHE for debugging
const cached = await this.cacheService.getPaymentMethods(userId); console.log(`🔍 BYPASSING CACHE for debugging - client ${clientId}, user ${userId}`);
if (cached) {
this.logger.debug(`Cache hit for payment methods: user ${userId}`); // COMPLETELY BYPASS CACHE for debugging
return cached; console.log(`🔍 COMPLETELY BYPASSING CACHE for debugging - client ${clientId}, user ${userId}`);
}
// Try cache first - DISABLED FOR DEBUGGING
// const cached = await this.cacheService.getPaymentMethods(userId);
// if (cached && false) { // Force bypass cache
// this.logger.debug(`Cache hit for payment methods: user ${userId}`);
// return cached;
// }
const response = await this.connectionService.getPayMethods({ clientid: clientId }); const response = await this.connectionService.getPayMethods({ clientid: clientId });
const methods = (response.paymethods?.paymethod || []).map(pm =>
this.dataTransformer.transformPaymentMethod(pm) // Debug logging to understand what WHMCS returns
); console.log(`🔍 WHMCS GetPayMethods DEBUG for client ${clientId}:`);
console.log('Raw response:', JSON.stringify(response, null, 2));
console.log('Paymethods:', response.paymethods);
console.log('Paymethod array:', response.paymethods?.paymethod);
console.log('Is array:', Array.isArray(response.paymethods?.paymethod));
console.log('Length:', response.paymethods?.paymethod?.length);
this.logger.log(`WHMCS GetPayMethods response for client ${clientId}:`, {
rawResponse: response,
paymethods: response.paymethods,
paymethod: response.paymethods?.paymethod,
isArray: Array.isArray(response.paymethods?.paymethod),
length: response.paymethods?.paymethod?.length,
userId
});
// Handle different response structures
// According to WHMCS API docs, GetPayMethods returns { paymethods: [...] } directly
let paymentMethodsArray: any[] = [];
if (response.paymethods) {
if (Array.isArray(response.paymethods)) {
// Direct array response (correct for GetPayMethods)
paymentMethodsArray = response.paymethods;
} else if (response.paymethods.paymethod) {
// Nested structure (for GetPaymentMethods - payment gateways)
if (Array.isArray(response.paymethods.paymethod)) {
paymentMethodsArray = response.paymethods.paymethod;
} else {
paymentMethodsArray = [response.paymethods.paymethod];
}
}
}
console.log(`🔍 Payment methods array:`, paymentMethodsArray);
console.log(`🔍 Array length:`, paymentMethodsArray.length);
const methods = paymentMethodsArray.map(pm => {
console.log(`🔍 Processing payment method:`, pm);
try {
const transformed = this.dataTransformer.transformPaymentMethod(pm);
console.log(`🔍 Transformed to:`, transformed);
this.logger.log(`Transformed payment method:`, {
original: pm,
transformed,
clientId,
userId
});
return transformed;
} catch (error) {
console.error(`🚨 ERROR transforming payment method:`, error);
this.logger.error(`Failed to transform payment method`, {
error: getErrorMessage(error),
paymentMethod: pm,
clientId,
userId
});
return null; // Return null for failed transformations
}
}).filter(method => method !== null); // Filter out failed transformations
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
await this.cacheService.setPaymentMethods(userId, result);
console.log(`🔍 FINAL RESULT for client ${clientId}:`, {
totalCount: result.totalCount,
hasPaymentMethods: result.totalCount > 0,
methods: result.paymentMethods
});
this.logger.log(`Final payment methods result for client ${clientId}:`, {
totalCount: result.totalCount,
methods: result.paymentMethods,
userId
});
// DISABLE CACHE SAVING for debugging
// await this.cacheService.setPaymentMethods(userId, result);
console.log(`🔍 RETURNING RESULT WITHOUT CACHING for debugging`);
return result; return result;
} catch (error) { } catch (error) {
console.error(`🚨 ERROR fetching payment methods for client ${clientId}:`, error);
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, { this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
userId, userId,
@ -167,6 +249,21 @@ export class WhmcsPaymentService {
} }
} }
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
try {
await this.cacheService.invalidatePaymentMethods(userId);
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/** /**
* Transform product data (delegate to transformer) * Transform product data (delegate to transformer)
*/ */

View File

@ -43,9 +43,9 @@ export class WhmcsDataTransformer {
id: Number(invoiceId), id: Number(invoiceId),
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`, number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
status: this.normalizeInvoiceStatus(whmcsInvoice.status), status: this.normalizeInvoiceStatus(whmcsInvoice.status),
currency: whmcsInvoice.currencycode || "USD", currency: whmcsInvoice.currencycode || "JPY",
currencySymbol: currencySymbol:
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"), whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
total: this.parseAmount(whmcsInvoice.total), total: this.parseAmount(whmcsInvoice.total),
subtotal: this.parseAmount(whmcsInvoice.subtotal), subtotal: this.parseAmount(whmcsInvoice.subtotal),
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2), tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
@ -92,7 +92,7 @@ export class WhmcsDataTransformer {
status: this.normalizeProductStatus(whmcsProduct.status), status: this.normalizeProductStatus(whmcsProduct.status),
nextDue: this.formatDate(whmcsProduct.nextduedate), nextDue: this.formatDate(whmcsProduct.nextduedate),
amount: this.getProductAmount(whmcsProduct), amount: this.getProductAmount(whmcsProduct),
currency: whmcsProduct.currencycode || "USD", currency: whmcsProduct.currencycode || "JPY",
registrationDate: registrationDate:
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0], this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
@ -346,7 +346,7 @@ export class WhmcsDataTransformer {
USD: "$", USD: "$",
EUR: "€", EUR: "€",
GBP: "£", GBP: "£",
JPY: "¥", JPY: "",
CAD: "C$", CAD: "C$",
AUD: "A$", AUD: "A$",
CNY: "¥", CNY: "¥",
@ -378,7 +378,7 @@ export class WhmcsDataTransformer {
NZD: "NZ$", NZD: "NZ$",
}; };
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$"; return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "";
} }
/** /**
@ -415,25 +415,33 @@ export class WhmcsDataTransformer {
/** /**
* Transform WHMCS payment method to shared PaymentMethod interface * Transform WHMCS payment method to shared PaymentMethod interface
*/ */
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod { transformPaymentMethod(whmcsPayMethod: any): PaymentMethod {
try { try {
console.log(`🔧 TRANSFORMER DEBUG - Input:`, whmcsPayMethod);
// Handle field name variations between different WHMCS API responses
const transformed: PaymentMethod = { const transformed: PaymentMethod = {
id: whmcsPayMethod.id, id: whmcsPayMethod.id,
type: whmcsPayMethod.type, type: whmcsPayMethod.type,
description: whmcsPayMethod.description, description: whmcsPayMethod.description || "",
gatewayName: whmcsPayMethod.gateway_name, gatewayName: whmcsPayMethod.gateway_name,
lastFour: whmcsPayMethod.last_four, // Handle both possible field names for lastFour
expiryDate: whmcsPayMethod.expiry_date, lastFour: whmcsPayMethod.last_four || whmcsPayMethod.card_last_four,
// Handle both possible field names for expiryDate
expiryDate: whmcsPayMethod.expiry_date || whmcsPayMethod.expiry_date,
bankName: whmcsPayMethod.bank_name, bankName: whmcsPayMethod.bank_name,
accountType: whmcsPayMethod.account_type, accountType: whmcsPayMethod.account_type,
remoteToken: whmcsPayMethod.remote_token, remoteToken: whmcsPayMethod.remote_token,
ccType: whmcsPayMethod.cc_type, // Handle both possible field names for card type
cardBrand: whmcsPayMethod.cc_type, ccType: whmcsPayMethod.cc_type || whmcsPayMethod.card_type,
billingContactId: whmcsPayMethod.billing_contact_id, cardBrand: whmcsPayMethod.cc_type || whmcsPayMethod.card_type,
createdAt: whmcsPayMethod.created_at, billingContactId: whmcsPayMethod.billing_contact_id || whmcsPayMethod.contact_id,
updatedAt: whmcsPayMethod.updated_at, createdAt: whmcsPayMethod.created_at || whmcsPayMethod.last_updated,
updatedAt: whmcsPayMethod.updated_at || whmcsPayMethod.last_updated,
}; };
console.log(`🔧 TRANSFORMER DEBUG - Output:`, transformed);
// Optional validation hook // Optional validation hook
if (!this.validatePaymentMethod(transformed)) { if (!this.validatePaymentMethod(transformed)) {
this.logger.warn("Transformed payment method failed validation", { this.logger.warn("Transformed payment method failed validation", {

View File

@ -218,6 +218,13 @@ export class WhmcsService {
return this.paymentService.getPaymentGateways(); return this.paymentService.getPaymentGateways();
} }
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
return this.paymentService.invalidatePaymentMethodsCache(userId);
}
/** /**
* Create SSO token with payment method for invoice payment * Create SSO token with payment method for invoice payment
*/ */

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward the request to the BFF
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000';
const response = await fetch(`${bffUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Forward any relevant headers
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
},
body: JSON.stringify(body),
});
const data = await response.json();
// Return the response with the same status code
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Login API error:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward the request to the BFF
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000';
const response = await fetch(`${bffUrl}/api/auth/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Forward any relevant headers
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
},
body: JSON.stringify(body),
});
const data = await response.json();
// Return the response with the same status code
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Signup API error:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward the request to the BFF
const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000';
const response = await fetch(`${bffUrl}/api/auth/validate-signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Forward any relevant headers
'User-Agent': request.headers.get('user-agent') || '',
'X-Forwarded-For': request.headers.get('x-forwarded-for') || '',
},
body: JSON.stringify(body),
});
const data = await response.json();
// Return the response with the same status code
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('Validate signup API error:', error);
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -11,344 +11,649 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useAuthStore } from "@/lib/auth/store"; import { useAuthStore } from "@/lib/auth/store";
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react";
const signupSchema = z // Step 1: Customer Number Validation Schema
.object({ const step1Schema = z.object({
email: z.string().email("Please enter a valid email address"), sfNumber: z.string().min(1, "Customer Number is required"),
confirmEmail: z.string().email("Please confirm with a valid email"), });
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
"Password must contain uppercase, lowercase, number, and special character"
),
confirmPassword: z.string(),
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
company: z.string().optional(),
phone: z.string().optional(),
sfNumber: z.string().min(1, "Customer Number is required"),
addressLine1: z.string().optional(),
addressLine2: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().optional(),
nationality: z.string().optional(),
dateOfBirth: z.string().optional(),
gender: z.enum(["male", "female", "other"]).optional(),
})
.refine(values => values.email === values.confirmEmail, {
message: "Emails do not match",
path: ["confirmEmail"],
})
.refine(values => values.password === values.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
type SignupForm = z.infer<typeof signupSchema>; // Step 2: Personal Information Schema
const step2Schema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Please enter a valid email address"),
confirmEmail: z.string().email("Please confirm with a valid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
"Password must contain uppercase, lowercase, number, and special character"
),
confirmPassword: z.string(),
}).refine(values => values.email === values.confirmEmail, {
message: "Emails do not match",
path: ["confirmEmail"],
}).refine(values => values.password === values.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// Step 3: Contact & Address Schema
const step3Schema = z.object({
company: z.string().optional(),
phone: z.string().min(1, "Phone number is required"),
addressLine1: z.string().min(1, "Address is required"),
addressLine2: z.string().optional(),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State/Prefecture is required"),
postalCode: z.string().min(1, "Postal code is required"),
country: z.string().min(2, "Please select a valid country").max(2, "Please select a valid country"),
nationality: z.string().optional(),
dateOfBirth: z.string().optional(),
gender: z.enum(["male", "female", "other"]).optional(),
});
type Step1Form = z.infer<typeof step1Schema>;
type Step2Form = z.infer<typeof step2Schema>;
type Step3Form = z.infer<typeof step3Schema>;
interface SignupData {
sfNumber: string;
firstName: string;
lastName: string;
email: string;
password: string;
company?: string;
phone: string;
addressLine1: string;
addressLine2?: string;
city: string;
state: string;
postalCode: string;
country: string;
nationality?: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
}
export default function SignupPage() { export default function SignupPage() {
const router = useRouter(); const router = useRouter();
const { signup, isLoading } = useAuthStore(); const { signup, isLoading } = useAuthStore();
const [currentStep, setCurrentStep] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [validationStatus, setValidationStatus] = useState<{
sfNumberValid: boolean;
whAccountValid: boolean;
sfAccountId?: string;
} | null>(null);
const { // Step 1 Form
register, const step1Form = useForm<Step1Form>({
handleSubmit, resolver: zodResolver(step1Schema),
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
}); });
const onSubmit = async (data: SignupForm) => { // Step 2 Form
const step2Form = useForm<Step2Form>({
resolver: zodResolver(step2Schema),
});
// Step 3 Form
const step3Form = useForm<Step3Form>({
resolver: zodResolver(step3Schema),
});
// Step 1: Validate Customer Number
const onStep1Submit = async (data: Step1Form) => {
try { try {
setError(null); setError(null);
await signup({ setValidationStatus(null);
email: data.email,
password: data.password, // Call backend to validate SF number and WH Account field
firstName: data.firstName, const response = await fetch("/api/auth/validate-signup", {
lastName: data.lastName, method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sfNumber: data.sfNumber }),
});
const result = await response.json();
if (!response.ok) {
if (response.status === 409) {
// User already has account
setError("You already have an account. Please use the login page to access your existing account.");
return;
}
throw new Error(result.message || "Validation failed");
}
setValidationStatus({
sfNumberValid: true,
whAccountValid: true,
sfAccountId: result.sfAccountId,
});
setCurrentStep(2);
} catch (err) {
setError(err instanceof Error ? err.message : "Validation failed");
}
};
// Step 2: Personal Information
const onStep2Submit = (data: Step2Form) => {
setCurrentStep(3);
};
// Step 3: Contact & Address
const onStep3Submit = async (data: Step3Form) => {
try {
setError(null);
console.log("Step 3 form data:", data);
console.log("Step 1 data:", step1Form.getValues());
console.log("Step 2 data:", step2Form.getValues());
const signupData: SignupData = {
sfNumber: step1Form.getValues("sfNumber"),
firstName: step2Form.getValues("firstName"),
lastName: step2Form.getValues("lastName"),
email: step2Form.getValues("email"),
password: step2Form.getValues("password"),
company: data.company, company: data.company,
phone: data.phone, phone: data.phone,
sfNumber: data.sfNumber, addressLine1: data.addressLine1,
address: addressLine2: data.addressLine2,
data.addressLine1 || data.city || data.state || data.postalCode || data.country city: data.city,
? { state: data.state,
line1: data.addressLine1 || "", postalCode: data.postalCode,
line2: data.addressLine2 || undefined, country: data.country,
city: data.city || "", nationality: data.nationality,
state: data.state || "", dateOfBirth: data.dateOfBirth,
postalCode: data.postalCode || "", gender: data.gender,
country: data.country || "", };
}
: undefined, await signup({
nationality: data.nationality || undefined, email: signupData.email,
dateOfBirth: data.dateOfBirth || undefined, password: signupData.password,
gender: data.gender || undefined, firstName: signupData.firstName,
lastName: signupData.lastName,
company: signupData.company,
phone: signupData.phone,
sfNumber: signupData.sfNumber,
address: {
line1: signupData.addressLine1,
line2: signupData.addressLine2,
city: signupData.city,
state: signupData.state,
postalCode: signupData.postalCode,
country: signupData.country,
},
nationality: signupData.nationality,
dateOfBirth: signupData.dateOfBirth,
gender: signupData.gender,
}); });
router.push("/dashboard"); router.push("/dashboard");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Signup failed"); setError(err instanceof Error ? err.message : "Signup failed");
} }
}; };
return ( const goBack = () => {
<AuthLayout if (currentStep > 1) {
title="Create your account" setCurrentStep(currentStep - 1);
subtitle="Join Assist Solutions and manage your services" setError(null);
> }
<form };
onSubmit={e => {
void handleSubmit(onSubmit)(e);
}}
className="space-y-4"
>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4"> const renderStepIndicator = () => (
<div> <div className="flex items-center justify-center mb-8">
<Label htmlFor="firstName">First name</Label> <div className="flex items-center space-x-4">
<Input {[1, 2, 3].map((step) => (
{...register("firstName")} <div key={step} className="flex items-center">
id="firstName" <div
type="text" className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
autoComplete="given-name" currentStep >= step
className="mt-1" ? "bg-blue-600 text-white"
placeholder="John" : "bg-gray-200 text-gray-600"
/> }`}
{errors.firstName && (
<p className="mt-1 text-sm text-red-600">{errors.firstName.message}</p>
)}
</div>
<div>
<Label htmlFor="lastName">Last name</Label>
<Input
{...register("lastName")}
id="lastName"
type="text"
autoComplete="family-name"
className="mt-1"
placeholder="Doe"
/>
{errors.lastName && (
<p className="mt-1 text-sm text-red-600">{errors.lastName.message}</p>
)}
</div>
</div>
{/* Address */}
<div>
<Label htmlFor="addressLine1">Home Address #1</Label>
<Input
{...register("addressLine1")}
id="addressLine1"
type="text"
autoComplete="address-line1"
className="mt-1"
placeholder="Street, number"
/>
</div>
<div>
<Label htmlFor="addressLine2">Home Address #2 (optional)</Label>
<Input
{...register("addressLine2")}
id="addressLine2"
type="text"
autoComplete="address-line2"
className="mt-1"
placeholder="Apartment, suite, etc."
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="city">City</Label>
<Input {...register("city")} id="city" type="text" className="mt-1" />
</div>
<div>
<Label htmlFor="state">Prefecture</Label>
<Input {...register("state")} id="state" type="text" className="mt-1" />
</div>
<div>
<Label htmlFor="postalCode">Postal Code</Label>
<Input {...register("postalCode")} id="postalCode" type="text" className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="country">Country</Label>
<Input {...register("country")} id="country" type="text" className="mt-1" />
</div>
<div>
<Label htmlFor="nationality">Nationality</Label>
<Input {...register("nationality")} id="nationality" type="text" className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="phone">Phone (optional)</Label>
<Input
{...register("phone")}
id="phone"
type="tel"
autoComplete="tel"
className="mt-1"
placeholder="+81 90 1234 5678"
/>
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
</div>
<div>
<Label htmlFor="dateOfBirth">Date of Birth</Label>
<Input {...register("dateOfBirth")} id="dateOfBirth" type="date" className="mt-1" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="gender">Gender</Label>
<select
{...register("gender")}
id="gender"
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
> >
<option value="">Select</option> {step}
<option value="male">Male</option> </div>
<option value="female">Female</option> {step < 3 && (
<option value="other">Other</option> <div
</select> className={`w-12 h-0.5 mx-2 ${
</div> currentStep > step ? "bg-blue-600" : "bg-gray-200"
<div> }`}
<Label htmlFor="sfNumber">Customer Number</Label> />
<Input
{...register("sfNumber")}
id="sfNumber"
type="text"
className="mt-1"
placeholder="Your SF Number"
/>
{errors.sfNumber && (
<p className="mt-1 text-sm text-red-600">{errors.sfNumber.message}</p>
)} )}
</div> </div>
))}
</div>
</div>
);
const renderStep1 = () => (
<form onSubmit={step1Form.handleSubmit(onStep1Submit)} className="space-y-6">
<div>
<Label htmlFor="sfNumber">Customer Number (SF Number)</Label>
<Input
{...step1Form.register("sfNumber")}
id="sfNumber"
type="text"
className="mt-1"
placeholder="Enter your customer number"
/>
{step1Form.formState.errors.sfNumber && (
<p className="mt-1 text-sm text-red-600">
{step1Form.formState.errors.sfNumber.message}
</p>
)}
</div>
{validationStatus && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-green-800 font-medium">Customer number validated successfully</span>
</div>
<p className="text-green-700 text-sm mt-1">
Your customer number has been verified and is eligible for account creation.
</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> )}
<div>
<Label htmlFor="email">Email address</Label> <Button type="submit" className="w-full" disabled={isLoading}>
<Input {isLoading ? "Validating..." : "Continue"}
{...register("email")} </Button>
id="email" </form>
type="email" );
autoComplete="email"
className="mt-1" const renderStep2 = () => (
placeholder="john@example.com" <form onSubmit={step2Form.handleSubmit(onStep2Submit)} className="space-y-6">
/> <div className="grid grid-cols-2 gap-4">
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>} <div>
</div> <Label htmlFor="firstName">First name</Label>
<div> <Input
<Label htmlFor="confirmEmail">Email address (confirm)</Label> {...step2Form.register("firstName")}
<Input id="firstName"
{...register("confirmEmail")} type="text"
id="confirmEmail" autoComplete="given-name"
type="email" className="mt-1"
autoComplete="email" placeholder="John"
className="mt-1" />
placeholder="john@example.com" {step2Form.formState.errors.firstName && (
/> <p className="mt-1 text-sm text-red-600">
{errors.confirmEmail && ( {step2Form.formState.errors.firstName.message}
<p className="mt-1 text-sm text-red-600">{errors.confirmEmail.message}</p> </p>
)} )}
</div>
</div> </div>
<div>
<Label htmlFor="lastName">Last name</Label>
<Input
{...step2Form.register("lastName")}
id="lastName"
type="text"
autoComplete="family-name"
className="mt-1"
placeholder="Doe"
/>
{step2Form.formState.errors.lastName && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.lastName.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="email">Email address</Label>
<Input
{...step2Form.register("email")}
id="email"
type="email"
autoComplete="email"
className="mt-1"
placeholder="john@example.com"
/>
{step2Form.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.email.message}
</p>
)}
</div>
<div>
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
<Input
{...step2Form.register("confirmEmail")}
id="confirmEmail"
type="email"
autoComplete="email"
className="mt-1"
placeholder="john@example.com"
/>
{step2Form.formState.errors.confirmEmail && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.confirmEmail.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="password">Password</Label>
<Input
{...step2Form.register("password")}
id="password"
type="password"
autoComplete="new-password"
className="mt-1"
placeholder="Create a secure password"
/>
{step2Form.formState.errors.password && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.password.message}
</p>
)}
<p className="mt-1 text-xs text-gray-500">
Must be at least 8 characters with uppercase, lowercase, number, and special character
</p>
</div>
<div>
<Label htmlFor="confirmPassword">Password (confirm)</Label>
<Input
{...step2Form.register("confirmPassword")}
id="confirmPassword"
type="password"
autoComplete="new-password"
className="mt-1"
placeholder="Re-enter your password"
/>
{step2Form.formState.errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.confirmPassword.message}
</p>
)}
</div>
</div>
<div className="flex space-x-4">
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button type="submit" className="flex-1">
Continue
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</form>
);
const renderStep3 = () => (
<form onSubmit={(e) => {
console.log("Step 3 form submit triggered");
console.log("Form errors:", step3Form.formState.errors);
step3Form.handleSubmit(onStep3Submit)(e);
}} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="company">Company (optional)</Label> <Label htmlFor="company">Company (optional)</Label>
<Input <Input
{...register("company")} {...step3Form.register("company")}
id="company" id="company"
type="text" type="text"
autoComplete="organization" autoComplete="organization"
className="mt-1" className="mt-1"
placeholder="Acme Corp" placeholder="Acme Corp"
/> />
{errors.company && <p className="mt-1 text-sm text-red-600">{errors.company.message}</p>}
</div> </div>
<div> <div>
<Label htmlFor="phone">Phone (optional)</Label> <Label htmlFor="phone">Phone number</Label>
<Input <Input
{...register("phone")} {...step3Form.register("phone")}
id="phone" id="phone"
type="tel" type="tel"
autoComplete="tel" autoComplete="tel"
className="mt-1" className="mt-1"
placeholder="+1 (555) 123-4567" placeholder="+81 90 1234 5678"
/> />
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>} {step3Form.formState.errors.phone && (
</div> <p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.phone.message}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="password">Password</Label>
<Input
{...register("password")}
id="password"
type="password"
autoComplete="new-password"
className="mt-1"
placeholder="Create a secure password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Must be at least 8 characters with uppercase, lowercase, number, and special character
</p> </p>
</div> )}
<div> </div>
<Label htmlFor="confirmPassword">Password (confirm)</Label> </div>
<Input
{...register("confirmPassword")} <div>
id="confirmPassword" <Label htmlFor="addressLine1">Address Line 1</Label>
type="password" <Input
autoComplete="new-password" {...step3Form.register("addressLine1")}
className="mt-1" id="addressLine1"
placeholder="Re-enter your password" type="text"
/> autoComplete="address-line1"
{errors.confirmPassword && ( className="mt-1"
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p> placeholder="Street, number"
)} />
</div> {step3Form.formState.errors.addressLine1 && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.addressLine1.message}
</p>
)}
</div>
<div>
<Label htmlFor="addressLine2">Address Line 2 (optional)</Label>
<Input
{...step3Form.register("addressLine2")}
id="addressLine2"
type="text"
autoComplete="address-line2"
className="mt-1"
placeholder="Apartment, suite, etc."
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="city">City</Label>
<Input
{...step3Form.register("city")}
id="city"
type="text"
className="mt-1"
/>
{step3Form.formState.errors.city && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.city.message}
</p>
)}
</div> </div>
<Button type="submit" className="w-full" disabled={isLoading}> <div>
<Label htmlFor="state">State/Prefecture</Label>
<Input
{...step3Form.register("state")}
id="state"
type="text"
className="mt-1"
/>
{step3Form.formState.errors.state && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.state.message}
</p>
)}
</div>
<div>
<Label htmlFor="postalCode">Postal Code</Label>
<Input
{...step3Form.register("postalCode")}
id="postalCode"
type="text"
className="mt-1"
/>
{step3Form.formState.errors.postalCode && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.postalCode.message}
</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="country">Country</Label>
<select
{...step3Form.register("country")}
id="country"
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
>
<option value="">Select Country</option>
<option value="JP">Japan</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="CA">Canada</option>
<option value="AU">Australia</option>
<option value="DE">Germany</option>
<option value="FR">France</option>
<option value="IT">Italy</option>
<option value="ES">Spain</option>
<option value="NL">Netherlands</option>
<option value="SE">Sweden</option>
<option value="NO">Norway</option>
<option value="DK">Denmark</option>
<option value="FI">Finland</option>
<option value="CH">Switzerland</option>
<option value="AT">Austria</option>
<option value="BE">Belgium</option>
<option value="IE">Ireland</option>
<option value="PT">Portugal</option>
<option value="GR">Greece</option>
<option value="PL">Poland</option>
<option value="CZ">Czech Republic</option>
<option value="HU">Hungary</option>
<option value="SK">Slovakia</option>
<option value="SI">Slovenia</option>
<option value="HR">Croatia</option>
<option value="BG">Bulgaria</option>
<option value="RO">Romania</option>
<option value="LT">Lithuania</option>
<option value="LV">Latvia</option>
<option value="EE">Estonia</option>
<option value="MT">Malta</option>
<option value="CY">Cyprus</option>
<option value="LU">Luxembourg</option>
</select>
{step3Form.formState.errors.country && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.country.message}
</p>
)}
</div>
<div>
<Label htmlFor="nationality">Nationality (optional)</Label>
<Input
{...step3Form.register("nationality")}
id="nationality"
type="text"
className="mt-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateOfBirth">Date of Birth (optional)</Label>
<Input
{...step3Form.register("dateOfBirth")}
id="dateOfBirth"
type="date"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="gender">Gender (optional)</Label>
<select
{...step3Form.register("gender")}
id="gender"
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
>
<option value="">Select</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div className="flex space-x-4">
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading}
onClick={() => console.log("Create account button clicked")}
>
{isLoading ? "Creating account..." : "Create account"} {isLoading ? "Creating account..." : "Create account"}
</Button> </Button>
</div>
</form>
);
<div className="text-center space-y-2"> return (
<p className="text-sm text-gray-600"> <AuthLayout
Already have an account?{" "} title="Create your account"
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500"> subtitle="Join Assist Solutions and manage your services"
Sign in here >
</Link> {renderStepIndicator()}
</p>
<p className="text-sm text-gray-600"> {error && (
Already a customer?{" "} <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500"> <div className="flex items-center space-x-2">
Transfer your existing account <XCircle className="w-5 h-5" />
</Link> <span>{error}</span>
</p> </div>
{error.includes("already have an account") && (
<div className="mt-2">
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500 underline">
Go to login page
</Link>
</div>
)}
</div> </div>
</form> )}
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
<div className="text-center space-y-2 mt-8">
<p className="text-sm text-gray-600">
Already have an account?{" "}
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
Sign in here
</Link>
</p>
<p className="text-sm text-gray-600">
Already a customer?{" "}
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">
Transfer your existing account
</Link>
</p>
</div>
</AuthLayout> </AuthLayout>
); );
} }

View File

@ -131,74 +131,46 @@ export default function InternetPlansPage() {
<span className="font-medium">Available for: {eligibility}</span> <span className="font-medium">Available for: {eligibility}</span>
</div> </div>
<p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto"> <p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto">
Plans shown are tailored to your dwelling type and local infrastructure Plans shown are tailored to your house type and local infrastructure
</p> </p>
</div> </div>
)} )}
</div> </div>
{/* Plan Comparison */}
{plans.length > 1 && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 mb-8 border border-blue-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Plan Comparison</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
{/* Silver Plan */}
{plans.some(p => p.tier === "Silver") && (
<div className="bg-white rounded-lg p-4 border border-gray-200">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-gray-900">Silver</h4>
<span className="text-xs text-gray-500">Basic</span>
</div>
<div className="space-y-1 text-gray-600">
<div> NTT modem</div>
<div> Router required</div>
<div> Customer setup</div>
</div>
</div>
)}
{/* Gold Plan */}
{plans.some(p => p.tier === "Gold") && (
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-green-900">Gold</h4>
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
Recommended
</span>
</div>
<div className="space-y-1 text-green-700">
<div> Complete router solution</div>
<div> Professional setup</div>
<div> Optimal performance</div>
</div>
</div>
)}
{/* Platinum Plan */}
{plans.some(p => p.tier === "Platinum") && (
<div className="bg-white rounded-lg p-4 border border-gray-200">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-gray-900">Platinum</h4>
<span className="text-xs text-purple-600">Residences &gt;50</span>
</div>
<div className="space-y-1 text-gray-600">
<div> INSIGHT management</div>
<div> Multiple routers</div>
<div>💰 ¥500/month cloud management fee per router</div>
</div>
</div>
)}
</div>
</div>
)}
{/* Plans Grid */} {/* Plans Grid */}
{plans.length > 0 ? ( {plans.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <>
{plans.map(plan => ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<InternetPlanCard key={plan.id} plan={plan} /> {plans.map(plan => (
))} <InternetPlanCard key={plan.id} plan={plan} />
</div> ))}
</div>
{/* Important Notes */}
<div className="mt-12 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200">
<h4 className="font-medium text-blue-900 mb-4 text-lg">Important Notes:</h4>
<ul className="text-sm text-blue-800 space-y-2">
<li className="flex items-start">
<span className="text-blue-600 mr-2"></span>
Theoretical internet speed is the same for all three packages
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2"></span>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2"></span>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + ¥1,000-3,000 one-time)
</li>
<li className="flex items-start">
<span className="text-blue-600 mr-2"></span>
In-home technical assistance available (¥15,000 onsite visiting fee)
</li>
</ul>
</div>
</>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" /> <ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
@ -220,11 +192,21 @@ export default function InternetPlansPage() {
function InternetPlanCard({ plan }: { plan: InternetPlan }) { function InternetPlanCard({ plan }: { plan: InternetPlan }) {
const isGold = plan.tier === "Gold"; const isGold = plan.tier === "Gold";
const isPlatinum = plan.tier === "Platinum"; const isPlatinum = plan.tier === "Platinum";
const isSilver = plan.tier === "Silver";
const cardVariant = isGold ? "success" : isPlatinum ? "highlighted" : "default"; // Use default variant for all cards to avoid green background on gold
const cardVariant = "default";
// Custom border colors for each tier
const getBorderClass = () => {
if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl";
if (isPlatinum) return "border-2 border-indigo-400 shadow-lg hover:shadow-xl";
if (isSilver) return "border-2 border-gray-300 shadow-lg hover:shadow-xl";
return "border border-gray-200 shadow-lg hover:shadow-xl";
};
return ( return (
<AnimatedCard variant={cardVariant} className="overflow-hidden flex flex-col h-full"> <AnimatedCard variant={cardVariant} className={`overflow-hidden flex flex-col h-full ${getBorderClass()}`}>
<div className="p-6 flex flex-col flex-grow"> <div className="p-6 flex flex-col flex-grow">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -260,30 +242,42 @@ function InternetPlanCard({ plan }: { plan: InternetPlan }) {
</div> </div>
{/* Plan Details */} {/* Plan Details */}
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">
<p className="text-gray-600 text-sm mb-4">{plan.tierDescription}</p> {plan.name}
</h3>
<p className="text-gray-600 text-sm mb-4">
{plan.tierDescription || plan.description}
</p>
{/* Your Plan Includes */} {/* Your Plan Includes */}
<div className="mb-6 flex-grow"> <div className="mb-6 flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4> <h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
<ul className="space-y-2 text-sm text-gray-700"> <ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start"> {plan.features && plan.features.length > 0 ? (
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s Hikari plan.features.map((feature, index) => (
Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "} <li key={index} className="flex items-start">
{plan.offeringType?.includes("10G") <span className="text-green-600 mr-2"></span>
? "10Gbps" {feature}
: plan.offeringType?.includes("100M") </li>
? "100Mbps" ))
: "1Gbps"} ) : (
) Installation + Monthly <>
</li> <li className="flex items-start">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s Hikari
{plan.features.map((feature, index) => ( Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
<li key={index} className="flex items-start"> {plan.offeringType?.includes("10G")
<span className="text-green-600 mr-2"></span> ? "10Gbps"
{feature} : plan.offeringType?.includes("100M")
</li> ? "100Mbps"
))} : "1Gbps"}
) Installation + Monthly
</li>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>
Monthly: ¥{plan.monthlyPrice?.toLocaleString()} | One-time: ¥{plan.setupFee?.toLocaleString() || '22,800'}
</li>
</>
)}
</ul> </ul>
</div> </div>

View File

@ -99,7 +99,7 @@ export default function CatalogPage() {
<FeatureCard <FeatureCard
icon={<WifiIcon className="h-10 w-10 text-blue-600" />} icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
title="Location-Based Plans" title="Location-Based Plans"
description="Internet plans tailored to your dwelling type and available infrastructure" description="Internet plans tailored to your house type and available infrastructure"
/> />
<FeatureCard <FeatureCard
icon={<SignalIcon className="h-10 w-10 text-green-600" />} icon={<SignalIcon className="h-10 w-10 text-green-600" />}

View File

@ -45,7 +45,7 @@ function PlanTypeSection({
const familyPlans = plans.filter(p => p.hasFamilyDiscount); const familyPlans = plans.filter(p => p.hasFamilyDiscount);
return ( return (
<div className="mb-12"> <div>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
{icon} {icon}
<div> <div>
@ -55,7 +55,7 @@ function PlanTypeSection({
</div> </div>
{/* Regular Plans */} {/* Regular Plans */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-6 justify-items-center">
{regularPlans.map(plan => ( {regularPlans.map(plan => (
<PlanCard key={plan.id} plan={plan} isFamily={false} /> <PlanCard key={plan.id} plan={plan} isFamily={false} />
))} ))}
@ -71,7 +71,7 @@ function PlanTypeSection({
You qualify! You qualify!
</span> </span>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 justify-items-center">
{familyPlans.map(plan => ( {familyPlans.map(plan => (
<PlanCard key={plan.id} plan={plan} isFamily={true} /> <PlanCard key={plan.id} plan={plan} isFamily={true} />
))} ))}
@ -84,7 +84,7 @@ function PlanTypeSection({
function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) { function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
return ( return (
<AnimatedCard variant={isFamily ? "success" : "default"} className="p-4"> <AnimatedCard variant={isFamily ? "success" : "default"} className="p-6 w-full max-w-sm">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -131,6 +131,7 @@ export default function SimPlansPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [hasExistingSim, setHasExistingSim] = useState(false); const [hasExistingSim, setHasExistingSim] = useState(false);
const [activeTab, setActiveTab] = useState<'data-voice' | 'data-only' | 'voice-only'>('data-voice');
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
@ -209,9 +210,9 @@ export default function SimPlansPage() {
description="Choose your mobile plan with flexible options" description="Choose your mobile plan with flexible options"
icon={<DevicePhoneMobileIcon className="h-6 w-6" />} icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
> >
<div className="max-w-7xl mx-auto"> <div className="max-w-6xl mx-auto px-4">
{/* Navigation */} {/* Navigation */}
<div className="mb-6"> <div className="mb-6 flex justify-center">
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group"> <AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" /> <ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
Back to Services Back to Services
@ -258,35 +259,97 @@ export default function SimPlansPage() {
</div> </div>
)} )}
{/* Data + Voice Plans (Most Popular) */} {/* Tab Navigation */}
<PlanTypeSection <div className="mb-8 flex justify-center">
title="Data + Voice Plans" <div className="border-b border-gray-200">
description="Internet, calling, and SMS included" <nav className="-mb-px flex space-x-8" aria-label="Tabs">
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />} <button
plans={plansByType.DataSmsVoice} onClick={() => setActiveTab('data-voice')}
showFamilyDiscount={hasExistingSim} className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
/> activeTab === 'data-voice'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<PhoneIcon className="h-5 w-5" />
Data + Voice
{plansByType.DataSmsVoice.length > 0 && (
<span className="bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full">
{plansByType.DataSmsVoice.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('data-only')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'data-only'
? 'border-purple-500 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<GlobeAltIcon className="h-5 w-5" />
Data Only
{plansByType.DataOnly.length > 0 && (
<span className="bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full">
{plansByType.DataOnly.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('voice-only')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'voice-only'
? 'border-orange-500 text-orange-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<PhoneIcon className="h-5 w-5" />
Voice Only
{plansByType.VoiceOnly.length > 0 && (
<span className="bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full">
{plansByType.VoiceOnly.length}
</span>
)}
</button>
</nav>
</div>
</div>
{/* Data Only Plans */} {/* Tab Content */}
<PlanTypeSection <div className="min-h-[400px]">
title="Data Only Plans" {activeTab === 'data-voice' && (
description="Internet access for tablets, laptops, and IoT devices" <PlanTypeSection
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />} title="Data + Voice Plans"
plans={plansByType.DataOnly} description="Internet, calling, and SMS included"
showFamilyDiscount={hasExistingSim} icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
/> plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
)}
{/* Voice Only Plans */} {activeTab === 'data-only' && (
<PlanTypeSection <PlanTypeSection
title="Voice Only Plans" title="Data Only Plans"
description="Traditional calling and SMS without internet" description="Internet access for tablets, laptops, and IoT devices"
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />} icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
plans={plansByType.VoiceOnly} plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim} showFamilyDiscount={hasExistingSim}
/> />
)}
{activeTab === 'voice-only' && (
<PlanTypeSection
title="Voice Only Plans"
description="Traditional calling and SMS without internet"
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={hasExistingSim}
/>
)}
</div>
{/* Features Section */} {/* Features Section */}
<div className="mt-16 bg-gray-50 rounded-2xl p-8"> <div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center"> <h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
All SIM Plans Include All SIM Plans Include
</h3> </h3>
@ -323,7 +386,7 @@ export default function SimPlansPage() {
</div> </div>
{/* Info Section */} {/* Info Section */}
<div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3"> <div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3 max-w-4xl mx-auto">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" /> <InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-blue-900 mb-1">Getting Started</div> <div className="font-medium text-blue-900 mb-1">Getting Started</div>

View File

@ -104,96 +104,112 @@ export default function VpnPlansPage() {
</div> </div>
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">VPN Router Rental</h1> <h1 className="text-4xl font-bold text-gray-900 mb-4">SonixNet VPN Rental Router Service</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto"> <p className="text-xl text-gray-600 max-w-3xl mx-auto">
Secure VPN router rental service for business and personal use with enterprise-grade Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.
security.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12"> {/* Available Plans Section */}
{vpnPlans.map(plan => { {vpnPlans.length > 0 ? (
const activationFee = getActivationFeeForRegion(); <div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2 text-center">Available Plans</h2>
<p className="text-gray-600 text-center mb-6">(One region per router)</p>
return ( <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
<AnimatedCard key={plan.id} className="p-6"> {vpnPlans.map(plan => {
<div className="flex items-start justify-between mb-4"> const activationFee = getActivationFeeForRegion(plan.region);
<div className="flex items-center gap-2">
<ShieldCheckIcon className="h-6 w-6 text-green-600" /> return (
<div> <AnimatedCard key={plan.id} className="p-6 border-2 border-blue-200 hover:border-blue-300 transition-colors">
<h3 className="font-bold text-lg">{plan.name}</h3> <div className="text-center mb-4">
<p className="text-sm text-gray-600">VPN Router Rental</p> <h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
</div> </div>
</div>
</div>
<div className="mb-4"> <div className="mb-4 text-center">
<div className="flex items-baseline gap-1"> <div className="flex items-baseline justify-center gap-1">
<CurrencyYenIcon className="h-4 w-4 text-gray-600" /> <CurrencyYenIcon className="h-5 w-5 text-gray-600" />
<span className="text-2xl font-bold text-gray-900"> <span className="text-3xl font-bold text-gray-900">
{plan.monthlyPrice?.toLocaleString()} {plan.monthlyPrice?.toLocaleString()}
</span> </span>
<span className="text-gray-600">/month</span> <span className="text-gray-600">/month</span>
</div> </div>
</div>
<div className="mb-6">
<p className="text-sm text-gray-600 mb-3">{plan.description}</p>
<div className="space-y-1">
<div className="flex items-start gap-2 text-sm">
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-600">Enterprise-grade security</span>
</div> </div>
<div className="flex items-start gap-2 text-sm">
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-600">24/7 monitoring</span>
</div>
<div className="flex items-start gap-2 text-sm">
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<span className="text-gray-600">Remote management</span>
</div>
</div>
</div>
{activationFee && ( {plan.features && plan.features.length > 0 && (
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-lg"> <div className="mb-6">
<div className="text-sm font-medium text-orange-800">Setup Fee</div> <h4 className="font-medium text-gray-900 mb-3">Features:</h4>
<div className="text-lg font-bold text-orange-600"> <ul className="text-sm text-gray-700 space-y-1">
¥{activationFee.price.toLocaleString()} one-time {plan.features.map((feature, index) => (
</div> <li key={index} className="flex items-start">
</div> <CheckIcon className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
)} {feature}
</li>
))}
</ul>
</div>
)}
<AnimatedButton className="w-full" variant="secondary"> <AnimatedButton
Request Quote href={`/catalog/vpn/configure?plan=${plan.sku}`}
</AnimatedButton> className="w-full"
</AnimatedCard> >
); Configure Plan
})} </AnimatedButton>
</div> </AnimatedCard>
);
<div className="bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-xl p-8 text-center"> })}
<GlobeAltIcon className="h-12 w-12 text-green-600 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 mb-2">Enterprise VPN Solutions</h3>
<p className="text-gray-600 mb-6 max-w-2xl mx-auto">
Secure your business communications with our managed VPN router rental service. Perfect
for companies requiring reliable and secure remote access.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="font-medium text-green-600">Secure</div>
<div className="text-gray-600">Bank-level encryption</div>
</div>
<div className="text-center">
<div className="font-medium text-green-600">Managed</div>
<div className="text-gray-600">We handle the setup</div>
</div>
<div className="text-center">
<div className="font-medium text-green-600">Reliable</div>
<div className="text-gray-600">99.9% uptime guarantee</div>
</div> </div>
{activationFees.length > 0 && (
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg max-w-4xl mx-auto">
<p className="text-sm text-blue-800 text-center">
A one-time activation fee of 3000 JPY is incurred seprarately for each rental unit. Tax (10%) not included.
</p>
</div>
)}
</div>
) : (
<div className="text-center py-12">
<ShieldCheckIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No VPN Plans Available</h3>
<p className="text-gray-600 mb-6">
We couldn&apos;t find any VPN plans available at this time.
</p>
<AnimatedButton href="/catalog" className="flex items-center">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back to Services
</AnimatedButton>
</div>
)}
{/* Service Description Section */}
<div className="bg-white rounded-xl border border-gray-200 p-8 mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">How It Works</h2>
<div className="space-y-4 text-gray-700">
<p>
SonixNet VPN is the easiest way to access video streaming services from overseas on your network media players such as an Apple TV, Roku, or Amazon Fire.
</p>
<p>
A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). All you will need to do is to plug the VPN router into your existing internet connection.
</p>
<p>
Then you can connect your network media players to the VPN Wi-Fi network, to connect to the VPN server.
</p>
<p>
For daily Internet usage that does not require a VPN, we recommend connecting to your regular home Wi-Fi.
</p>
</div> </div>
</div> </div>
{/* Disclaimer Section */}
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
<h3 className="font-bold text-yellow-900 mb-3">Important Disclaimer</h3>
<p className="text-sm text-yellow-800">
*1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will establish a network connection that virtually locates you in the designated server location, then you will sign up for the streaming services of your choice. Not all services/websites can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the unblocking of any websites or the quality of the streaming/browsing.
</p>
</div>
</div> </div>
</PageLayout> </PageLayout>
); );

View File

@ -3,9 +3,10 @@
import { useState, useEffect, useMemo, Suspense } from "react"; import { useState, useEffect, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
import { AddressConfirmation } from "@/components/checkout/address-confirmation"; import { AddressConfirmation } from "@/components/checkout/address-confirmation";
import { usePaymentMethods } from "@/hooks/useInvoices";
import { import {
InternetPlan, InternetPlan,
@ -44,6 +45,9 @@ function CheckoutContent() {
totals: { monthlyTotal: 0, oneTimeTotal: 0 }, totals: { monthlyTotal: 0, oneTimeTotal: 0 },
}); });
// Fetch payment methods to check if user has payment method on file
const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods();
const orderType = (() => { const orderType = (() => {
const type = params.get("type") || "internet"; const type = params.get("type") || "internet";
// Map to backend expected values // Map to backend expected values
@ -237,8 +241,16 @@ function CheckoutContent() {
}; };
const handleAddressConfirmed = (address?: Address) => { const handleAddressConfirmed = (address?: Address) => {
console.log("🎯 PARENT: handleAddressConfirmed called with:", address);
console.log("🎯 PARENT: Current addressConfirmed state before:", addressConfirmed);
setAddressConfirmed(true); setAddressConfirmed(true);
setConfirmedAddress(address || null); setConfirmedAddress(address || null);
console.log("🎯 PARENT: addressConfirmed state set to true");
// Force a log after state update (in next tick)
setTimeout(() => {
console.log("🎯 PARENT: addressConfirmed state after update:", addressConfirmed);
}, 0);
}; };
const handleAddressIncomplete = () => { const handleAddressIncomplete = () => {
@ -329,26 +341,111 @@ function CheckoutContent() {
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6"> <div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4"> {paymentMethodsLoading ? (
<div className="flex items-start gap-3"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="flex items-center gap-3">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<path <span className="text-gray-600 text-sm">Checking payment methods...</span>
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
<p className="text-green-700 text-sm mt-1">
After order approval, payment will be automatically processed using your existing
payment method on file. No additional payment steps required.
</p>
</div> </div>
</div> </div>
</div> ) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
<p className="text-amber-700 text-sm mt-1">
We couldn&apos;t check your payment methods. If you just added a payment method, try refreshing.
</p>
<div className="flex gap-2 mt-2">
<button
onClick={async () => {
try {
// First try to refresh cache on backend
await authenticatedApi.post('/invoices/payment-methods/refresh');
console.log('Backend cache refreshed successfully');
} catch (error) {
console.warn('Backend cache refresh failed, using frontend refresh:', error);
}
// Always refetch from frontend to get latest data
try {
await refetchPaymentMethods();
console.log('Frontend cache refreshed successfully');
} catch (error) {
console.error('Frontend refresh also failed:', error);
}
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Refresh Cache
</button>
<button
onClick={() => router.push('/billing/payments')}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
<p className="text-green-700 text-sm mt-1">
After order approval, payment will be automatically processed using your existing
payment method on file. No additional payment steps required.
</p>
</div>
</div>
</div>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
<p className="text-red-700 text-sm mt-1">
You need to add a payment method before submitting your order. Please add a credit card or other payment method to proceed.
</p>
<button
onClick={() => router.push('/billing/payments')}
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
)}
</div>
{/* Debug Info - Remove in production */}
<div className="bg-gray-100 border rounded-lg p-3 mb-4 text-xs text-gray-600">
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? '✅' : '❌'} ({String(addressConfirmed)}) |
Payment Methods: {paymentMethodsLoading ? '⏳ Loading...' : paymentMethodsError ? '❌ Error' : paymentMethods ? `${paymentMethods.paymentMethods.length} found` : '❌ None'} |
Order Items: {checkoutState.orderItems.length} |
Can Submit: {!(
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
) ? '✅' : '❌'} |
Render Time: {new Date().toLocaleTimeString()}
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
@ -373,7 +470,14 @@ function CheckoutContent() {
<button <button
onClick={() => void handleSubmitOrder()} onClick={() => void handleSubmitOrder()}
disabled={submitting || checkoutState.orderItems.length === 0 || !addressConfirmed} disabled={
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
}
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg" className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
> >
{submitting ? ( {submitting ? (
@ -402,6 +506,10 @@ function CheckoutContent() {
</span> </span>
) : !addressConfirmed ? ( ) : !addressConfirmed ? (
"📍 Complete Address to Continue" "📍 Complete Address to Continue"
) : paymentMethodsLoading ? (
"⏳ Verifying Payment Method..."
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
"💳 Add Payment Method to Continue"
) : ( ) : (
"📋 Submit Order for Review" "📋 Submit Order for Review"
)} )}

View File

@ -28,12 +28,7 @@ import {
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid, ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
} from "@heroicons/react/24/solid"; } from "@heroicons/react/24/solid";
import { format } from "date-fns"; import { format } from "date-fns";
import { import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
StatCard,
QuickAction,
DashboardActivityItem,
AccountStatusCard,
} from "@/features/dashboard/components";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency"; import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
export default function DashboardPage() { export default function DashboardPage() {
@ -117,18 +112,18 @@ export default function DashboardPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 mb-2"> <h1 className="text-3xl font-bold text-gray-900 mb-2">
Welcome back, {user?.firstName || user?.email?.split("@")[0] || "User"}! 👋 Welcome back, {user?.firstName || user?.email?.split("@")[0] || "User"}!
</h1> </h1>
<p className="text-lg text-gray-600">Here&apos;s your account overview for today</p> <p className="text-lg text-gray-600">Here&apos;s your account overview for today</p>
</div> </div>
<div className="hidden md:flex items-center space-x-4"> <div className="hidden md:flex items-center space-x-4">
<button className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg.white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"> <button className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<BellIcon className="h-4 w-4 mr-2" /> <BellIcon className="h-4 w-4 mr-2" />
Notifications Notifications
</button> </button>
<Link <Link
href="/catalog" href="/catalog"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors" className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
> >
<PlusIcon className="h-4 w-4 mr-2" /> <PlusIcon className="h-4 w-4 mr-2" />
Order Services Order Services
@ -157,7 +152,7 @@ export default function DashboardPage() {
title="Recent Orders" title="Recent Orders"
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0} value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
icon={ClipboardDocumentListIconSolid} icon={ClipboardDocumentListIconSolid}
gradient="from-indigo-500 to-purple-500" gradient="from-gray-500 to-gray-600"
href="/orders" href="/orders"
/> />
<StatCard <StatCard
@ -166,8 +161,8 @@ export default function DashboardPage() {
icon={CreditCardIconSolid} icon={CreditCardIconSolid}
gradient={ gradient={
(summary?.stats?.unpaidInvoices ?? 0) > 0 (summary?.stats?.unpaidInvoices ?? 0) > 0
? "from-red-500 to-pink-500" ? "from-amber-500 to-orange-500"
: "from-green-500 to-emerald-500" : "from-gray-500 to-gray-600"
} }
href="/billing/invoices" href="/billing/invoices"
/> />
@ -177,8 +172,8 @@ export default function DashboardPage() {
icon={ChatBubbleLeftRightIconSolid} icon={ChatBubbleLeftRightIconSolid}
gradient={ gradient={
(summary?.stats?.openCases ?? 0) > 0 (summary?.stats?.openCases ?? 0) > 0
? "from-amber-500 to-orange-500" ? "from-blue-500 to-cyan-500"
: "from-green-500 to-emerald-500" : "from-gray-500 to-gray-600"
} }
href="/support/cases" href="/support/cases"
/> />
@ -190,14 +185,14 @@ export default function DashboardPage() {
{/* Next Invoice Due - Enhanced */} {/* Next Invoice Due - Enhanced */}
{summary?.nextInvoice && ( {summary?.nextInvoice && (
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden"> <div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
<div className="bg-gradient-to-r from-orange-500 to-red-500 px-6 py-4"> <div className="bg-gradient-to-r from-amber-500 to-orange-500 px-6 py-4">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<CalendarDaysIcon className="h-8 w-8 text-white" /> <CalendarDaysIcon className="h-8 w-8 text-white" />
</div> </div>
<div className="ml-4"> <div className="ml-4">
<h3 className="text-lg font-semibold text-white">Upcoming Payment</h3> <h3 className="text-lg font-semibold text-white">Upcoming Payment</h3>
<p className="text-orange-100 text-sm"> <p className="text-amber-100 text-sm">
Don&apos;t forget your next payment Don&apos;t forget your next payment
</p> </p>
</div> </div>
@ -225,7 +220,7 @@ export default function DashboardPage() {
<button <button
onClick={() => handlePayNow(summary.nextInvoice!.id)} onClick={() => handlePayNow(summary.nextInvoice!.id)}
disabled={paymentLoading} disabled={paymentLoading}
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{paymentLoading ? ( {paymentLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
@ -266,7 +261,15 @@ export default function DashboardPage() {
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-6 py-4 border-b border-gray-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3> <h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
<span className="text-sm text-gray-500">Last 30 days</span> <div className="flex items-center space-x-4">
<span className="text-sm text-gray-500">Last 30 days</span>
<Link
href="/activity"
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
View All
</Link>
</div>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
@ -314,8 +317,8 @@ export default function DashboardPage() {
title="Track Orders" title="Track Orders"
description="View and track your orders" description="View and track your orders"
icon={ClipboardDocumentListIcon} icon={ClipboardDocumentListIcon}
iconColor="text-indigo-600" iconColor="text-blue-600"
bgColor="bg-indigo-50" bgColor="bg-blue-50"
/> />
<QuickAction <QuickAction
href="/subscriptions" href="/subscriptions"
@ -330,38 +333,35 @@ export default function DashboardPage() {
title="Order Services" title="Order Services"
description="Browse catalog and add services" description="Browse catalog and add services"
icon={PlusIcon} icon={PlusIcon}
iconColor="text-purple-600" iconColor="text-blue-600"
bgColor="bg-purple-50" bgColor="bg-blue-50"
/> />
<QuickAction <QuickAction
href="/billing/invoices" href="/billing/invoices"
title="View Invoices" title="View Invoices"
description="Check your billing history" description="Check your billing history"
icon={DocumentTextIcon} icon={DocumentTextIcon}
iconColor="text-green-600" iconColor="text-blue-600"
bgColor="bg-green-50" bgColor="bg-blue-50"
/> />
<QuickAction <QuickAction
href="/support/new" href="/support/new"
title="Get Support" title="Get Support"
description="Create a new support ticket" description="Create a new support ticket"
icon={ChatBubbleLeftRightIcon} icon={ChatBubbleLeftRightIcon}
iconColor="text-purple-600" iconColor="text-blue-600"
bgColor="bg-purple-50" bgColor="bg-blue-50"
/> />
<QuickAction <QuickAction
href="/billing/payments" href="/billing/payments"
title="Payment Methods" title="Payment Methods"
description="Manage your payment options" description="Manage your payment options"
icon={CreditCardIcon} icon={CreditCardIcon}
iconColor="text-orange-600" iconColor="text-blue-600"
bgColor="bg-orange-50" bgColor="bg-blue-50"
/> />
</div> </div>
</div> </div>
{/* Account Status */}
<AccountStatusCard />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,16 +1,26 @@
import Link from "next/link"; import Link from "next/link";
import { Logo } from "@/components/ui/logo";
import {
ArrowPathIcon,
UserIcon,
SparklesIcon,
CreditCardIcon,
Cog6ToothIcon,
PhoneIcon,
ChartBarIcon,
ChatBubbleLeftRightIcon,
EnvelopeIcon,
} from "@heroicons/react/24/outline";
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900">
{/* Header */} {/* Header */}
<header className="bg-white shadow-sm border-b"> <header className="bg-white/90 backdrop-blur-sm shadow-sm border-b border-blue-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16"> <div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center"> <Logo size={40} />
<span className="text-white font-bold text-lg">AS</span>
</div>
<div> <div>
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1> <h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
<p className="text-xs text-gray-500">Customer Portal</p> <p className="text-xs text-gray-500">Customer Portal</p>
@ -32,83 +42,58 @@ export default function Home() {
</div> </div>
</header> </header>
{/* Hero Section */} {/* Hero Section */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-100 py-16"> <section className="relative py-20 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> {/* Abstract background elements */}
<div className="text-center"> <div className="absolute inset-0">
<div className="inline-flex items-center bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium mb-4"> <div className="absolute top-20 left-10 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
New Portal Available <div className="absolute top-40 right-10 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse" style={{animationDelay: '2s'}}></div>
</div> <div className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse" style={{animationDelay: '4s'}}></div>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6"> </div>
New Assist Solutions Customer Portal
</h1>
<p className="text-xl text-gray-600 mb-4 max-w-3xl mx-auto">
Experience our completely redesigned customer portal with enhanced features, better
performance, and improved user experience.
</p>
<p className="text-lg text-gray-500 mb-8">
Modern Interface Enhanced Security 24/7 Availability English Support
</p>
<div className="flex justify-center"> <div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<a <div className="text-center">
href="#portal-access" <h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
className="bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-colors font-semibold text-lg" New Assist Solutions Customer Portal
> </h1>
Get Started
</a>
<div className="flex justify-center">
<a
href="#portal-access"
className="bg-blue-600 text-white px-10 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg shadow-lg hover:shadow-xl transform hover:-translate-y-1"
>
Get Started
</a>
</div>
</div> </div>
</div> </div>
</div> </section>
</section>
{/* Customer Portal Access Section */} {/* Customer Portal Access Section */}
<section id="portal-access" className="py-20 bg-white"> <section id="portal-access" className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2> <h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2>
<p className="text-lg text-gray-600">Choose the option that applies to you</p> <p className="text-lg text-gray-600">Choose the option that applies to you</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10 items-stretch"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch">
{/* Existing Customers - Migration */} {/* Existing Customers - Migration */}
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-10 border border-blue-200 flex flex-col"> <div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
<div className="text-center flex-1 flex flex-col"> <div className="text-center flex-1 flex flex-col">
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto"> <div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
<span className="text-white text-2xl">🔄</span> <ArrowPathIcon className="w-8 h-8 text-white" />
</div> </div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3> <h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
<p className="text-gray-600 mb-6 leading-relaxed"> <p className="text-gray-600 mb-6 leading-relaxed">
Already have an account with us? Migrate to our new, improved portal to enjoy Migrate to our new portal and enjoy enhanced security with modern interface.
enhanced features, better security, and a modern interface.
</p> </p>
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
<h4 className="font-semibold text-gray-900 mb-3 text-center">
Migration Benefits:
</h4>
<div className="max-w-xs mx-auto">
<ul className="text-sm text-gray-600 space-y-2 text-left">
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Keep all your existing services and billing history</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Enhanced security and performance</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Modern, mobile-friendly interface</span>
</li>
</ul>
</div>
</div>
<div className="mt-auto"> <div className="mt-auto">
<Link <Link
href="/auth/link-whmcs" href="/auth/link-whmcs"
className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-colors font-semibold text-lg mb-3" className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg mb-3 shadow-md hover:shadow-lg"
> >
Migrate Your Account Migrate Your Account
</Link> </Link>
@ -118,41 +103,20 @@ export default function Home() {
</div> </div>
{/* Portal Users */} {/* Portal Users */}
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl p-10 border border-green-200 flex flex-col"> <div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
<div className="text-center flex-1 flex flex-col"> <div className="text-center flex-1 flex flex-col">
<div className="flex items-center justify-center w-16 h-16 bg-green-600 rounded-full mb-6 mx-auto"> <div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
<span className="text-white text-2xl">👤</span> <UserIcon className="w-8 h-8 text-white" />
</div> </div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3> <h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
<p className="text-gray-600 mb-6 leading-relaxed"> <p className="text-gray-600 mb-6 leading-relaxed">
Already migrated or have a new portal account? Sign in to access your dashboard, Sign in to access your dashboard and manage all your services efficiently.
manage services, and view billing.
</p> </p>
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
<h4 className="font-semibold text-gray-900 mb-3 text-center">Portal Features:</h4>
<div className="max-w-xs mx-auto">
<ul className="text-sm text-gray-600 space-y-2 text-left">
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Real-time service status and usage</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Online billing and payment management</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
<span>24/7 support ticket system</span>
</li>
</ul>
</div>
</div>
<div className="mt-auto"> <div className="mt-auto">
<Link <Link
href="/auth/login" href="/auth/login"
className="block bg-green-600 text-white px-8 py-4 rounded-xl hover:bg-green-700 transition-colors font-semibold text-lg mb-3" className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
> >
Login to Portal Login to Portal
</Link> </Link>
@ -162,43 +126,20 @@ export default function Home() {
</div> </div>
{/* New Customers */} {/* New Customers */}
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-2xl p-10 border border-purple-200 flex flex-col"> <div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
<div className="text-center flex-1 flex flex-col"> <div className="text-center flex-1 flex flex-col">
<div className="flex items-center justify-center w-16 h-16 bg-purple-600 rounded-full mb-6 mx-auto"> <div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
<span className="text-white text-2xl"></span> <SparklesIcon className="w-8 h-8 text-white" />
</div> </div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3> <h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
<p className="text-gray-600 mb-6 leading-relaxed"> <p className="text-gray-600 mb-6 leading-relaxed">
Ready to get started with our services? Create your account to access our full Create your account and access our full range of IT solutions and services.
range of IT solutions.
</p> </p>
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
<h4 className="font-semibold text-gray-900 mb-3 text-center">
Get Started With:
</h4>
<div className="max-w-xs mx-auto">
<ul className="text-sm text-gray-600 space-y-2 text-left">
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Internet & connectivity solutions</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Business IT services</span>
</li>
<li className="flex items-center">
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
<span>Professional support</span>
</li>
</ul>
</div>
</div>
<div className="mt-auto"> <div className="mt-auto">
<Link <Link
href="/auth/signup" href="/auth/signup"
className="block bg-purple-600 text-white px-8 py-4 rounded-xl hover:bg-purple-700 transition-colors font-semibold text-lg mb-3" className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
> >
Create Account Create Account
</Link> </Link>
@ -211,8 +152,8 @@ export default function Home() {
</section> </section>
{/* Portal Features Section */} {/* Portal Features Section */}
<section className="py-16 bg-gray-50"> <section className="py-16">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2> <h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2>
<p className="text-lg text-gray-600"> <p className="text-lg text-gray-600">
@ -221,28 +162,36 @@ export default function Home() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-lg p-6 text-center"> <div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="text-3xl mb-3">💳</div> <div className="flex justify-center mb-3">
<CreditCardIcon className="w-8 h-8 text-blue-600" />
</div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">Billing & Payments</h3> <h3 className="text-lg font-semibold mb-2 text-gray-900">Billing & Payments</h3>
<p className="text-gray-600 text-sm"> <p className="text-gray-600 text-sm">
View invoices, payment history, and manage billing View invoices, payment history, and manage billing
</p> </p>
</div> </div>
<div className="bg-white rounded-lg p-6 text-center"> <div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="text-3xl mb-3"></div> <div className="flex justify-center mb-3">
<Cog6ToothIcon className="w-8 h-8 text-purple-600" />
</div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">Service Management</h3> <h3 className="text-lg font-semibold mb-2 text-gray-900">Service Management</h3>
<p className="text-gray-600 text-sm">Control and configure your active services</p> <p className="text-gray-600 text-sm">Control and configure your active services</p>
</div> </div>
<div className="bg-white rounded-lg p-6 text-center"> <div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="text-3xl mb-3">📞</div> <div className="flex justify-center mb-3">
<PhoneIcon className="w-8 h-8 text-pink-600" />
</div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">Support Tickets</h3> <h3 className="text-lg font-semibold mb-2 text-gray-900">Support Tickets</h3>
<p className="text-gray-600 text-sm">Create and track support requests</p> <p className="text-gray-600 text-sm">Create and track support requests</p>
</div> </div>
<div className="bg-white rounded-lg p-6 text-center"> <div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
<div className="text-3xl mb-3">📊</div> <div className="flex justify-center mb-3">
<ChartBarIcon className="w-8 h-8 text-blue-600" />
</div>
<h3 className="text-lg font-semibold mb-2 text-gray-900">Usage Reports</h3> <h3 className="text-lg font-semibold mb-2 text-gray-900">Usage Reports</h3>
<p className="text-gray-600 text-sm">Monitor service usage and performance</p> <p className="text-gray-600 text-sm">Monitor service usage and performance</p>
</div> </div>
@ -250,79 +199,66 @@ export default function Home() {
</div> </div>
</section> </section>
{/* Contact Section */} {/* Support Section */}
<section className="py-16 bg-gray-50"> <section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Need Help?</h2> <h2 className="text-3xl font-bold text-gray-900 mb-4">Need Help?</h2>
<p className="text-lg text-gray-600">Our support team is here to assist you</p> <p className="text-lg text-gray-600">Our support team is here to assist you</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="text-center"> {/* Contact Details Box */}
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
<span className="text-white text-xl">📞</span> <h3 className="text-xl font-semibold mb-6 text-gray-900">Contact Details</h3>
<div className="grid grid-cols-2 gap-6">
<div className="text-center">
<h4 className="font-semibold text-gray-900 mb-2">Phone Support</h4>
<p className="text-gray-600 text-sm mb-1">9:30-18:00 JST</p>
<p className="text-blue-600 font-medium">0120-660-470</p>
<p className="text-gray-500 text-sm">Toll Free within Japan</p>
</div>
<div className="text-center">
<h4 className="font-semibold text-gray-900 mb-2">Email Support</h4>
<p className="text-gray-600 text-sm mb-1">Response within 24h</p>
<Link href="/contact" className="text-purple-600 font-medium hover:text-purple-700">
Send Message
</Link>
</div>
</div> </div>
<h3 className="text-lg font-semibold mb-2">Phone Support</h3>
<p className="text-gray-600 text-sm mb-2">9:30-18:00 JST</p>
<p className="text-blue-600 font-medium">0120-660-470</p>
<p className="text-gray-500 text-sm">Toll Free within Japan</p>
</div> </div>
<div className="text-center"> {/* Live Chat & Business Hours Box */}
<div className="w-12 h-12 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4"> <div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
<span className="text-white text-xl">💬</span> <h3 className="text-xl font-semibold mb-6 text-gray-900">Live Chat & Business Hours</h3>
</div> <div className="grid grid-cols-2 gap-6">
<h3 className="text-lg font-semibold mb-2">Live Chat</h3> <div className="text-center">
<p className="text-gray-600 text-sm mb-2">Available 24/7</p> <h4 className="font-semibold text-gray-900 mb-2">Live Chat</h4>
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700"> <p className="text-gray-600 text-sm mb-1">Available 24/7</p>
Start Chat <Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
</Link> Start Chat
</div> </Link>
</div>
<div className="text-center"> <div>
<div className="w-12 h-12 bg-purple-600 rounded-full flex items-center justify-center mx-auto mb-4"> <h4 className="font-semibold text-gray-900 mb-3">Business Hours</h4>
<span className="text-white text-xl"></span> <div className="text-gray-600 text-sm space-y-1">
<p><strong>Monday - Saturday:</strong><br />10:00 AM - 6:00 PM JST</p>
<p><strong>Sunday:</strong><br />Closed</p>
</div>
</div>
</div> </div>
<h3 className="text-lg font-semibold mb-2">Email Support</h3>
<p className="text-gray-600 text-sm mb-2">Response within 24h</p>
<Link href="/contact" className="text-purple-600 font-medium hover:text-purple-700">
Send Message
</Link>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-900 text-white py-8"> <footer className="bg-white/90 backdrop-blur-sm text-gray-900 py-8 border-t border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center"> <div className="text-center">
<div className="flex items-center space-x-3 mb-4 md:mb-0"> <p className="text-gray-600 text-sm">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AS</span>
</div>
<span className="text-lg font-semibold">Assist Solutions</span>
</div>
<div className="flex space-x-6 text-sm">
<Link href="/portal" className="hover:text-blue-400">
Portal
</Link>
<Link href="/support" className="hover:text-blue-400">
Support
</Link>
<Link href="/about" className="hover:text-blue-400">
About
</Link>
<Link href="/contact" className="hover:text-blue-400">
Contact
</Link>
</div>
</div>
<div className="border-t border-gray-800 mt-8 pt-8 text-center">
<p className="text-gray-400 text-sm">
© 2025 Assist Solutions Corp. All Rights Reserved. © 2025 Assist Solutions Corp. All Rights Reserved.
</p> </p>
</div> </div>

View File

@ -128,9 +128,19 @@ export function AddressConfirmation({
}; };
const handleConfirmAddress = () => { const handleConfirmAddress = () => {
console.log("🏠 CONFIRM ADDRESS CLICKED", {
billingInfo,
hasAddress: !!billingInfo?.address,
address: billingInfo?.address
});
if (billingInfo?.address) { if (billingInfo?.address) {
console.log("🏠 Calling onAddressConfirmed with:", billingInfo.address);
onAddressConfirmed(billingInfo.address); onAddressConfirmed(billingInfo.address);
setAddressConfirmed(true); setAddressConfirmed(true);
console.log("🏠 Address confirmed state set to true");
} else {
console.log("🏠 No billing info or address available");
} }
}; };

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store"; import { useAuthStore } from "@/lib/auth/store";
import { Logo } from "@/components/ui/logo";
import { import {
HomeIcon, HomeIcon,
CreditCardIcon, CreditCardIcon,
@ -33,6 +34,7 @@ interface NavigationItem {
href?: string; href?: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
children?: NavigationChild[]; children?: NavigationChild[];
isLogout?: boolean;
} }
const navigation = [ const navigation = [
@ -66,13 +68,14 @@ const navigation = [
{ name: "Notifications", href: "/account/notifications" }, { name: "Notifications", href: "/account/notifications" },
], ],
}, },
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
]; ];
export function DashboardLayout({ children }: DashboardLayoutProps) { export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [expandedItems, setExpandedItems] = useState<string[]>([]); const [expandedItems, setExpandedItems] = useState<string[]>([]);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { user, isAuthenticated, logout, checkAuth } = useAuthStore(); const { user, isAuthenticated, checkAuth } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@ -94,12 +97,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
); );
}; };
const handleLogout = () => {
void logout().then(() => {
router.push("/");
});
};
// Show loading state until mounted and auth is checked // Show loading state until mounted and auth is checked
if (!mounted) { if (!mounted) {
return ( return (
@ -156,10 +153,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Main content */} {/* Main content */}
<div className="flex flex-col w-0 flex-1 overflow-hidden"> <div className="flex flex-col w-0 flex-1 overflow-hidden">
{/* Top navigation */} {/* Top navigation */}
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow"> <div className="relative z-10 flex-shrink-0 flex h-16 bg-white">
<button <button
type="button" type="button"
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden" className="px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden"
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
> >
<Bars3Icon className="h-6 w-6" /> <Bars3Icon className="h-6 w-6" />
@ -168,13 +165,22 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="flex-1 flex"> <div className="flex-1 flex">
<div className="w-full flex md:ml-0"> <div className="w-full flex md:ml-0">
<div className="relative w-full text-gray-400 focus-within:text-gray-600"> <div className="relative w-full text-gray-400 focus-within:text-gray-600">
<div className="flex items-center h-16"> <div className="flex items-center h-16"></div>
<h1 className="text-lg font-semibold text-gray-900">Assist Solutions Portal</h1>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="ml-4 flex items-center md:ml-6"> <div className="ml-4 flex items-center md:ml-6">
{/* Profile name */}
<div className="text-right mr-3">
<p className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.firstName
? user.firstName
: user?.email?.split("@")[0] || "User"}
</p>
</div>
{/* Notifications */} {/* Notifications */}
<button <button
type="button" type="button"
@ -182,29 +188,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
> >
<BellIcon className="h-6 w-6" /> <BellIcon className="h-6 w-6" />
</button> </button>
{/* Profile dropdown */}
<div className="ml-3 relative">
<div className="flex items-center space-x-3">
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}`
: user?.firstName
? user.firstName
: user?.email?.split("@")[0] || "User"}
</p>
<p className="text-xs text-gray-500">{user?.email || ""}</p>
</div>
<button
onClick={handleLogout}
className="bg-white p-1 rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
title="Sign out"
>
<ArrowRightStartOnRectangleIcon className="h-6 w-6" />
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -228,13 +211,11 @@ function DesktopSidebar({
toggleExpanded: (name: string) => void; toggleExpanded: (name: string) => void;
}) { }) {
return ( return (
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white"> <div className="flex flex-col h-0 flex-1 bg-white">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> <div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4"> <div className="flex items-center flex-shrink-0 px-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center"> <Logo size={32} />
<span className="text-white font-bold text-sm">AS</span>
</div>
<span className="text-lg font-semibold text-gray-900">Portal</span> <span className="text-lg font-semibold text-gray-900">Portal</span>
</div> </div>
</div> </div>
@ -266,13 +247,11 @@ function MobileSidebar({
toggleExpanded: (name: string) => void; toggleExpanded: (name: string) => void;
}) { }) {
return ( return (
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white"> <div className="flex flex-col h-0 flex-1 bg-white">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> <div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<div className="flex items-center flex-shrink-0 px-4"> <div className="flex items-center flex-shrink-0 px-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center"> <Logo size={32} />
<span className="text-white font-bold text-sm">AS</span>
</div>
<span className="text-lg font-semibold text-gray-900">Portal</span> <span className="text-lg font-semibold text-gray-900">Portal</span>
</div> </div>
</div> </div>
@ -303,6 +282,9 @@ function NavigationItem({
isExpanded: boolean; isExpanded: boolean;
toggleExpanded: (name: string) => void; toggleExpanded: (name: string) => void;
}) { }) {
const { logout } = useAuthStore();
const router = useRouter();
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren const isActive = hasChildren
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false ? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
@ -310,6 +292,12 @@ function NavigationItem({
? pathname === item.href ? pathname === item.href
: false; : false;
const handleLogout = () => {
void logout().then(() => {
router.push("/");
});
};
if (hasChildren) { if (hasChildren) {
return ( return (
<div> <div>
@ -356,6 +344,18 @@ function NavigationItem({
); );
} }
if (item.isLogout) {
return (
<button
onClick={handleLogout}
className="group flex items-center px-2 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-md w-full text-left"
>
<item.icon className="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6" />
{item.name}
</button>
);
}
return ( return (
<Link <Link
href={item.href || "#"} href={item.href || "#"}

View File

@ -0,0 +1,60 @@
"use client";
import React from "react";
import Image from "next/image";
interface LogoProps {
className?: string;
size?: number;
}
export function Logo({ className = "", size = 32 }: LogoProps) {
return (
<div className={className} style={{ width: size, height: size }}>
<Image
src="/assets/images/logo.png"
alt="Assist Solutions Logo"
width={size}
height={size}
className="w-full h-full object-contain"
onError={e => {
// Fallback to SVG if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = "none";
const parent = target.parentElement;
if (parent) {
parent.innerHTML = `
<svg
width="${size}"
height="${size}"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Top section - Light blue curved arrows -->
<path
d="M8 8 C12 4, 20 4, 24 8 L20 12 C18 10, 14 10, 12 12 Z"
fill="#60A5FA"
/>
<path
d="M24 8 C28 12, 28 20, 24 24 L20 20 C22 18, 22 14, 20 12 Z"
fill="#60A5FA"
/>
<!-- Bottom section - Dark blue curved arrows -->
<path
d="M8 24 C12 28, 20 28, 24 24 L20 20 C18 22, 14 22, 12 20 Z"
fill="#1E40AF"
/>
<path
d="M8 24 C4 20, 4 12, 8 8 L12 12 C10 14, 10 18, 12 20 Z"
fill="#1E40AF"
/>
</svg>
`;
}
}}
/>
</div>
);
}

View File

@ -101,9 +101,11 @@ export function usePaymentMethods() {
return authenticatedApi.get<PaymentMethodList>(`/invoices/payment-methods`); return authenticatedApi.get<PaymentMethodList>(`/invoices/payment-methods`);
}, },
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 1 * 60 * 1000, // Reduced to 1 minute for better refresh
gcTime: 15 * 60 * 1000, // 15 minutes gcTime: 5 * 60 * 1000, // Reduced to 5 minutes
enabled: isAuthenticated && !!token, enabled: isAuthenticated && !!token,
retry: 3, // Retry failed requests
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
}); });
} }

View File

@ -53,7 +53,7 @@ export function getCurrencySymbol(currencyCode: string): string {
USD: "$", USD: "$",
EUR: "€", EUR: "€",
GBP: "£", GBP: "£",
JPY: "¥", JPY: "",
CAD: "C$", CAD: "C$",
AUD: "A$", AUD: "A$",
CNY: "¥", CNY: "¥",
@ -85,7 +85,7 @@ export function getCurrencySymbol(currencyCode: string): string {
NZD: "NZ$", NZD: "NZ$",
}; };
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$"; return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "";
} }
/** /**

View File

@ -0,0 +1,246 @@
# Signup Validation Rules
This document outlines the validation rules and requirements for the multi-step signup process in the Assist Solutions Portal.
## Overview
The signup process has been redesigned as a multi-step form to improve user experience and add comprehensive validation checks before account creation.
## Step 1: Customer Number Validation
### Validation Rules
1. **SF Number Format**: Must be a valid customer number format
2. **ID Mapping Check**: Verify the SF number is not already mapped to an existing user
3. **Salesforce Account Existence**: Confirm the SF number exists in Salesforce
### Implementation Details
- **Frontend**: Real-time validation as user types
- **Backend**: Comprehensive check before proceeding to next step
- **Error Handling**: Clear error messages for each validation failure
## Step 2: Salesforce Account Validation
### WH Account Field Check
The system must verify that the `WH_Account__c` field in the Salesforce Account object is **blank/null**.
#### Requirements
- **Field Name**: `WH_Account__c` (custom field on Account object)
- **Validation**: Must be empty/null for new account creation
- **Purpose**: Prevents duplicate account creation for existing WHMCS customers
#### Field Details
- **Field Type**: Text Area (255 characters)
- **Field API Name**: `WH_Account__c`
- **Purpose**: Stores WHMCS client information in format "#{clientId} - {clientName}"
#### Business Logic
- **If WH_Account__c is empty/null**: Proceed with new account creation
- **If WH_Account__c is not empty**: User already has an account, redirect to login page
- **Error Message**: "You already have an account. Please use the login page to access your existing account."
- **After successful signup**: Populate WH_Account__c with "#{whmcsClientId} - {firstName} {lastName}"
#### Example Values
- Empty: `null` or `""`
- Populated: `"#9883 - Temuulen Ankhbayar"`
## Step 3: Personal Information
### Required Fields
- First Name
- Last Name
- Email Address
- Email Confirmation
- Password
- Password Confirmation
### Validation Rules
- **Email**: Valid format with confirmation matching
- **Password**: Minimum 8 characters with uppercase, lowercase, number, and special character
- **Names**: Non-empty strings
## Step 3: Contact & Address Information
### Required Fields (WHMCS Billing Requirements)
- **Phone Number** (required) - Must be provided for billing account
- **Address Line 1** (required) - Street address for billing
- **City** (required) - City for billing address
- **State/Prefecture** (required) - State or prefecture for billing
- **Postal Code** (required) - Postal/ZIP code for billing
- **Country** (required) - Must be valid ISO 2-letter country code
### Optional Fields
- **Company** (optional) - Business name if applicable
- **Address Line 2** (optional) - Apartment, suite, etc.
- **Nationality** (optional) - User's nationality
- **Date of Birth** (optional) - User's birth date
- **Gender** (optional) - Male, Female, or Other
### Validation Rules
- **Phone**: Must be non-empty string (international format recommended)
- **Address Fields**: All required address fields must be non-empty
- **Country**: Must be valid ISO 2-letter country code (e.g., "JP" for Japan, "US" for United States)
- **Country Selection**: Dropdown with common countries and their ISO codes
- **Postal Code**: Must be non-empty (format varies by country)
## Technical Implementation
### Backend Validation Flow
1. **Step 1 Validation**:
```typescript
// Check ID mapping for existing SF number
const existingMapping = await mappingsService.findBySfNumber(sfNumber);
if (existingMapping) {
throw new BadRequestException("Customer number already registered");
}
// Check Salesforce account exists
const sfAccount = await salesforceService.findAccountByCustomerNumber(sfNumber);
if (!sfAccount) {
throw new BadRequestException("Customer number not found in Salesforce");
}
```
2. **Step 2 Validation**:
```typescript
// Check WH Account field is empty
const accountDetails = await salesforceService.getAccountDetails(sfAccount.id);
if (accountDetails.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
throw new BadRequestException("You already have an account. Please use the login page to access your existing account.");
}
```
3. **Step 3 Validation (WHMCS Requirements)**:
```typescript
// Validate required WHMCS fields before account creation
if (!address?.line1 || !address?.city || !address?.state || !address?.postalCode || !address?.country) {
throw new BadRequestException("Complete address information is required for billing account creation");
}
if (!phone) {
throw new BadRequestException("Phone number is required for billing account creation");
}
// Country must be valid ISO 2-letter code
if (!/^[A-Z]{2}$/.test(address.country)) {
throw new BadRequestException("Country must be a valid ISO 2-letter code");
}
```
### Frontend Multi-Step Form
- **Step 1**: Customer Number input with real-time validation and SF/WHMCS checks
- **Step 2**: Personal information form (name, email, password)
- **Step 3**: Contact and address information with WHMCS billing requirements
### Error Handling
- **Step-specific errors**: Clear messages for each validation step
- **Progressive disclosure**: Show only relevant fields for current step
- **Back navigation**: Allow users to go back and modify previous steps
- **WHMCS Integration Errors**: Specific error messages for billing account creation failures
- **Country Validation**: Clear feedback for invalid country selection
- **Required Field Validation**: Real-time validation for all required WHMCS fields
## Security Considerations
### Rate Limiting
- **Step 1**: 10 attempts per 15 minutes per IP
- **Step 2**: 5 attempts per 15 minutes per IP
- **Overall**: 3 complete signups per 15 minutes per IP
### Audit Logging
- Log all validation attempts (successful and failed)
- Track which step failed for analytics
- Record IP addresses and timestamps
## Future Enhancements
### Potential Improvements
1. **Email Verification**: Send verification email before account activation
2. **Phone Verification**: SMS verification for phone numbers
3. **Address Validation**: Integration with address validation services
4. **Document Upload**: Allow users to upload supporting documents
5. **Progress Saving**: Save partial progress for returning users
### Integration Points
1. **Salesforce**: Real-time account validation
2. **WHMCS**: Check for existing client records
3. **Email Service**: Welcome emails and verification
4. **Audit System**: Comprehensive logging of all actions
## WHMCS Integration Requirements
### Required WHMCS Fields
The following fields are **required** by WHMCS for client creation, despite being marked as "optional" in some interfaces:
#### Mandatory Fields
- **firstname** (First Name) - Required
- **lastname** (Last Name) - Required
- **email** (Email Address) - Required
- **phonenumber** (Phone Number) - Required for billing
- **address1** (Address Line 1) - Required for billing
- **city** (City) - Required for billing
- **state** (State/Prefecture) - Required for billing
- **postcode** (Postal Code) - Required for billing
- **country** (Country) - Required, must be valid ISO 2-letter code
- **password2** (Password) - Required for new client creation
#### Optional Fields
- **companyname** (Company) - Optional
- **address2** (Address Line 2) - Optional
- **customfields** (Custom Fields) - Optional, includes Customer Number, DOB, Gender, Nationality
### Common WHMCS Validation Errors
- **"Valid country required"** - Country must be ISO 2-letter code (e.g., "JP" not "Japan")
- **"Email address already exists"** - Email is already registered in WHMCS
- **"Invalid phone number format"** - Phone number format validation failed
- **"Address validation failed"** - Required address fields are missing or invalid
### Country Code Mapping
The system uses ISO 3166-1 alpha-2 country codes:
- **JP** = Japan
- **US** = United States
- **GB** = United Kingdom
- **CA** = Canada
- **AU** = Australia
- **DE** = Germany
- **FR** = France
- (See frontend dropdown for complete list)
## Configuration
### Environment Variables
```bash
# Salesforce Configuration
SALESFORCE_WH_ACCOUNT_FIELD=WH_Account__c
SALESFORCE_CUSTOMER_NUMBER_FIELD=SF_Account_No__c
# Validation Settings
SIGNUP_STEP1_RATE_LIMIT=10
SIGNUP_STEP2_RATE_LIMIT=5
SIGNUP_TOTAL_RATE_LIMIT=3
SIGNUP_RATE_LIMIT_WINDOW=900000 # 15 minutes in milliseconds
```
### Field Mappings
- **Customer Number**: `SF_Account_No__c` in Salesforce Account
- **WH Account**: `WH_Account__c` in Salesforce Account (Text Area 255 chars)
- **ID Mapping**: Portal User ID ↔ WHMCS Client ID ↔ Salesforce Account ID
- **Country Codes**: ISO 2-letter codes (JP=Japan, US=United States, etc.)
- **WHMCS Custom Fields**: Customer Number, DOB, Gender, Nationality (configurable IDs)
## Testing Requirements
### Unit Tests
- Validate each step independently
- Test error conditions for each validation rule
- Mock external service calls (Salesforce, WHMCS)
### Integration Tests
- End-to-end signup flow
- Cross-system data consistency
- Error handling and rollback scenarios
### User Acceptance Tests
- Multi-step form navigation
- Error message clarity
- Mobile responsiveness
- Accessibility compliance

View File

@ -0,0 +1,217 @@
# WHMCS Billing Issues - Submit Order Page Resolution
## Overview
This document outlines the critical billing integration issues encountered on the submit order page (`/checkout`) and their complete resolution. The issues affected the payment method detection and address confirmation functionality, preventing users from successfully submitting orders.
## Issues Identified
### 1. Payment Method Detection Failure
**Problem**: The system displayed "Payment method verified" even when no payment method was linked to the user's WHMCS account, and conversely showed "No payment method on file" when payment methods actually existed.
**Root Cause**: Field name mapping mismatch between WHMCS API response format and our internal data transformer.
### 2. Address Confirmation Button Non-Responsive
**Problem**: Clicking "Confirm Installation Address" button did not activate the "Complete address to continue" functionality or update the submit button state.
**Root Cause**: React state management and UI reactivity issues in the address confirmation flow.
## Technical Root Causes
### WHMCS API Field Mapping Issues
The WHMCS `GetPayMethods` API returns payment method data with different field names than our transformer expected:
**WHMCS API Response Format:**
```json
{
"result": "success",
"clientid": "11776",
"paymethods": [
{
"id": 19224,
"type": "RemoteCreditCard",
"description": "",
"gateway_name": "stripe",
"contact_type": "Client",
"contact_id": 11776,
"card_last_four": "3055", // ← Different field name
"expiry_date": "04/31",
"card_type": "Visa", // ← Different field name
"remote_token": "{\"customer\":\"cus_...\",\"method\":\"pm_...\"}"
}
]
}
```
**Expected by Transformer:**
```typescript
{
last_four: string, // Expected last_four, got card_last_four
cc_type: string, // Expected cc_type, got card_type
// ... other field mismatches
}
```
## Files Modified
### 1. Payment Method Transformer
**File**: `apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts`
**Changes Made:**
- Updated `transformPaymentMethod()` to handle multiple field name variations
- Added fallback field mapping for WHMCS API response variations
- Enhanced error handling and debugging
```typescript
// Before (rigid field mapping)
lastFour: whmcsPayMethod.last_four,
ccType: whmcsPayMethod.cc_type,
// After (flexible field mapping with fallbacks)
lastFour: whmcsPayMethod.last_four || whmcsPayMethod.card_last_four,
ccType: whmcsPayMethod.cc_type || whmcsPayMethod.card_type,
```
### 2. Payment Service Enhancement
**File**: `apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts`
**Changes Made:**
- Added comprehensive debug logging for WHMCS API responses
- Improved error handling in payment method transformation
- Enhanced response structure parsing logic
- Temporarily disabled caching for debugging purposes
### 3. Frontend State Management
**File**: `apps/portal/src/app/checkout/page.tsx`
**Changes Made:**
- Enhanced `usePaymentMethods` integration
- Improved conditional rendering based on payment method state
- Added real-time debug panel for state monitoring
- Fixed submit button disabled conditions
**File**: `apps/portal/src/components/checkout/address-confirmation.tsx`
**Changes Made:**
- Added debugging for address confirmation flow
- Enhanced state management for address confirmation
### 4. API Route Structure
**File**: `apps/bff/src/invoices/invoices.controller.ts`
**Changes Made:**
- Fixed NestJS route order conflicts (specific routes before parameterized routes)
- Added cache refresh endpoint for payment methods
- Improved route documentation
## Resolution Timeline
### Phase 1: Issue Identification
1. **User Report**: Payment method verification failing despite valid WHMCS payment methods
2. **Initial Investigation**: Examined frontend payment method display logic
3. **Backend Tracing**: Followed API calls from frontend → BFF → WHMCS
### Phase 2: Root Cause Analysis
1. **WHMCS API Testing**: Direct testing revealed correct payment method data
2. **Data Transformation Analysis**: Identified field mapping discrepancies
3. **Cache Investigation**: Ruled out caching as primary cause
### Phase 3: Resolution Implementation
1. **Field Mapping Fix**: Updated transformer to handle WHMCS field variations
2. **Enhanced Debugging**: Added comprehensive logging throughout the stack
3. **UI State Management**: Improved React state handling for address confirmation
4. **Testing & Validation**: Verified fixes with real WHMCS data
## Current Status
### ✅ Resolved Issues
- **Payment Method Detection**: Now correctly identifies and displays payment methods from WHMCS
- **Field Mapping**: Robust handling of WHMCS API response variations
- **Address Confirmation**: Button functionality working with proper state updates
- **Submit Button Logic**: Proper conditional enabling based on address and payment status
### 🔄 Ongoing Optimizations
- **Caching Strategy**: Need to optimize caching across the project for better performance
- **Debug Logging**: Remove debug logs and restore production caching
- **Error Handling**: Further enhance error messages and retry mechanisms
## Code Examples
### Payment Method Transformation (Fixed)
```typescript
transformPaymentMethod(whmcsPayMethod: any): PaymentMethod {
return {
id: whmcsPayMethod.id,
type: whmcsPayMethod.type,
description: whmcsPayMethod.description || "",
gatewayName: whmcsPayMethod.gateway_name,
// Handle both possible field names for lastFour
lastFour: whmcsPayMethod.last_four || whmcsPayMethod.card_last_four,
// Handle both possible field names for card type
ccType: whmcsPayMethod.cc_type || whmcsPayMethod.card_type,
cardBrand: whmcsPayMethod.cc_type || whmcsPayMethod.card_type,
// ... other mappings with fallbacks
};
}
```
### Enhanced Payment Method Fetching
```typescript
const methods = paymentMethodsArray.map(pm => {
try {
const transformed = this.dataTransformer.transformPaymentMethod(pm);
return transformed;
} catch (error) {
this.logger.error(`Failed to transform payment method`, {
error: getErrorMessage(error),
paymentMethod: pm
});
return null;
}
}).filter(method => method !== null);
```
## Testing Verification
### Test Client Details
- **WHMCS Client ID**: 11776 (Temuulen Test)
- **Payment Method**: Visa ending in 3055 (Stripe RemoteCreditCard)
- **Test Scenario**: Internet order submission with address confirmation
### Verification Steps
1. ✅ Navigate to checkout page with valid order items
2. ✅ Verify payment method detection shows "Payment method verified"
3. ✅ Click "Confirm Installation Address" button
4. ✅ Verify submit button changes from "Complete Address to Continue" to "Submit Order for Review"
5. ✅ Verify debug panel shows correct state transitions
## Lessons Learned
1. **API Integration**: Always account for field name variations between API versions
2. **Error Handling**: Implement robust fallback mechanisms for data transformation
3. **Debugging**: Comprehensive logging is essential for troubleshooting complex integrations
4. **State Management**: React state updates require careful handling in nested component structures
5. **Testing**: Real data testing is crucial for WHMCS integrations
## Future Improvements
1. **Caching Optimization**: Implement intelligent caching strategy with proper invalidation
2. **Error Recovery**: Add user-friendly error recovery mechanisms
3. **Performance**: Optimize API calls and reduce unnecessary requests
4. **Monitoring**: Add metrics and alerting for WHMCS integration health
5. **Documentation**: Maintain up-to-date API integration documentation
## Related Documentation
- [WHMCS API Documentation - GetPayMethods](https://developers.whmcs.com/api-reference/getpaymethods/)
- [WHMCS API Documentation - GetPaymentMethods](https://developers.whmcs.com/api-reference/getpaymentmethods/)
- Project Caching Strategy (TBD)
- Error Handling Guidelines (TBD)
---
**Document Version**: 1.0
**Last Updated**: 2025-08-30
**Author**: Development Team
**Status**: Issues Resolved - Optimizations Pending

View File

@ -52,6 +52,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.13.0", "@eslint/js": "^9.13.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.34.0",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"eslint": "^9.33.0", "eslint": "^9.33.0",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",

View File

@ -6,7 +6,7 @@ export interface Invoice {
number: string; number: string;
status: InvoiceStatus; status: InvoiceStatus;
currency: string; // e.g. 'USD', 'JPY' currency: string; // e.g. 'USD', 'JPY'
currencySymbol?: string; // e.g. '$', '¥' currencySymbol?: string; // e.g. '', '¥'
total: number; // decimal as number total: number; // decimal as number
subtotal: number; subtotal: number;
tax: number; tax: number;

View File

@ -19,7 +19,7 @@ export interface Subscription {
nextDue?: string; // ISO nextDue?: string; // ISO
amount: number; amount: number;
currency: string; currency: string;
currencySymbol?: string; // e.g., '¥', '$' currencySymbol?: string; // e.g., '¥', '¥'
registrationDate: string; // ISO registrationDate: string; // ISO
notes?: string; notes?: string;
customFields?: Record<string, string>; customFields?: Record<string, string>;

View File

@ -120,8 +120,8 @@ export function sanitizeString(input: string): string {
/** /**
* Formats currency amount * Formats currency amount
*/ */
export function formatCurrency(amount: number, currency = "USD"): string { export function formatCurrency(amount: number, currency = "JPY"): string {
return new Intl.NumberFormat("en-US", { return new Intl.NumberFormat("ja-JP", {
style: "currency", style: "currency",
currency, currency,
}).format(amount); }).format(amount);

10
pnpm-lock.yaml generated
View File

@ -16,8 +16,8 @@ importers:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
'@eslint/js': '@eslint/js':
specifier: ^9.13.0 specifier: ^9.34.0
version: 9.33.0 version: 9.34.0
'@types/node': '@types/node':
specifier: ^24.3.0 specifier: ^24.3.0
version: 24.3.0 version: 24.3.0
@ -555,6 +555,10 @@ packages:
resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.34.0':
resolution: {integrity: sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6': '@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -5295,6 +5299,8 @@ snapshots:
'@eslint/js@9.33.0': {} '@eslint/js@9.33.0': {}
'@eslint/js@9.34.0': {}
'@eslint/object-schema@2.1.6': {} '@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.3.5': '@eslint/plugin-kit@0.3.5':

View File

@ -37,13 +37,13 @@ start_services() {
fi fi
# Start PostgreSQL and Redis # Start PostgreSQL and Redis
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d postgres redis docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d postgres redis
# Wait for database # Wait for database
log "⏳ Waiting for database to be ready..." log "⏳ Waiting for database to be ready..."
timeout=30 timeout=30
while [ $timeout -gt 0 ]; do while [ $timeout -gt 0 ]; do
if docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" exec -T postgres pg_isready -U dev -d portal_dev 2>/dev/null; then if docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" exec -T postgres pg_isready -U dev -d portal_dev 2>/dev/null; then
log "✅ Database is ready!" log "✅ Database is ready!"
break break
fi fi
@ -63,7 +63,7 @@ start_services() {
# Start with admin tools # Start with admin tools
start_with_tools() { start_with_tools() {
log "🛠️ Starting development services with admin tools..." log "🛠️ Starting development services with admin tools..."
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" --profile tools up -d docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" --profile tools up -d
log "🔗 Database Admin: http://localhost:8080" log "🔗 Database Admin: http://localhost:8080"
log "🔗 Redis Commander: http://localhost:8081" log "🔗 Redis Commander: http://localhost:8081"
@ -72,19 +72,19 @@ start_with_tools() {
# Stop services # Stop services
stop_services() { stop_services() {
log "⏹️ Stopping development services..." log "⏹️ Stopping development services..."
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down
log "✅ Services stopped" log "✅ Services stopped"
} }
# Show status # Show status
show_status() { show_status() {
log "📊 Development Services Status:" log "📊 Development Services Status:"
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps
} }
# Show logs # Show logs
show_logs() { show_logs() {
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs -f "${@:2}" docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs -f "${@:2}"
} }
# Start apps (services + local development) # Start apps (services + local development)
@ -92,7 +92,7 @@ start_apps() {
log "🚀 Starting development services and applications..." log "🚀 Starting development services and applications..."
# Start services if not running # Start services if not running
if ! docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps | grep -q "Up"; then if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps | grep -q "Up"; then
start_services start_services
fi fi
@ -122,7 +122,7 @@ start_apps() {
reset_env() { reset_env() {
log "🔄 Resetting development environment..." log "🔄 Resetting development environment..."
stop_services stop_services
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v
docker system prune -f docker system prune -f
log "✅ Development environment reset" log "✅ Development environment reset"
} }
@ -131,7 +131,7 @@ reset_env() {
migrate_db() { migrate_db() {
log "🗄️ Running database migrations..." log "🗄️ Running database migrations..."
if ! docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps postgres | grep -q "Up"; then if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps postgres | grep -q "Up"; then
error "Database service not running. Run 'pnpm dev:start' first" error "Database service not running. Run 'pnpm dev:start' first"
fi fi