Add new payment methods and health check endpoints in Auth and Invoices services

- Introduced `validateSignup` endpoint in AuthController for customer number validation during signup.
- Added `healthCheck` method in AuthService to verify service integrations and database connectivity.
- Implemented `getPaymentMethods`, `getPaymentGateways`, and `refreshPaymentMethods` endpoints in InvoicesController for managing user payment options.
- Enhanced InvoicesService with methods to invalidate payment methods cache and improved error handling.
- Updated currency handling across various services and components to reflect JPY as the default currency.
- Added new dependencies in package.json for ESLint configuration.
This commit is contained in:
tema 2025-08-30 15:10:24 +09:00
parent 1762d67e3f
commit 1640fae457
34 changed files with 2110 additions and 901 deletions

View File

@ -10,6 +10,7 @@ import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { SetPasswordDto } from "./dto/set-password.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { Public } from "./decorators/public.decorator";
@ApiTags("auth")
@ -17,6 +18,27 @@ import { Public } from "./decorators/public.decorator";
export class AuthController {
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()
@Post("signup")
@UseGuards(AuthThrottleGuard)

View File

@ -16,6 +16,7 @@ import { AuditService, AuditAction } from "../common/audit/audit.service";
import { TokenBlacklistService } from "./services/token-blacklist.service";
import { SignupDto } from "./dto/signup.dto";
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
import { ValidateSignupDto } from "./dto/validate-signup.dto";
import { SetPasswordDto } from "./dto/set-password.dto";
import { getErrorMessage } from "../common/utils/error.util";
import { Logger } from "nestjs-pino";
@ -43,6 +44,134 @@ export class AuthService {
@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) {
const {
email,
@ -106,36 +235,91 @@ export class AuthService {
});
// 2. Create client in WHMCS
// Prepare WHMCS custom fields (IDs configurable via env)
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
"198"
);
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");
let whmcsClient: { clientId: number };
try {
// Prepare WHMCS custom fields (IDs configurable via env)
const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
"198"
);
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> = {};
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfields[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
const customfields: Record<string, string> = {};
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
if (genderFieldId && gender) customfields[genderFieldId] = gender;
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
const whmcsClient: { clientId: number } = 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("Creating WHMCS client", { email, firstName, lastName, sfNumber });
// Validate required WHMCS fields
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");
}
this.logger.log("WHMCS client data", {
email,
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
await this.mappingsService.createMapping({
@ -144,11 +328,15 @@ export class AuthService {
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
await this.auditService.logAuthEvent(
AuditAction.SIGNUP,
user.id,
{ email, whmcsClientId: whmcsClient.clientId },
{ email, whmcsClientId: whmcsClient.clientId, whAccountValue },
request,
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[] }
> = {
Silver: {
description: "Basic setup - bring your own router",
tierDescription: "Basic",
description: "Simple package with broadband-modem and ISP only",
tierDescription: "Simple package with broadband-modem and ISP only",
features: [
"1 NTT Modem (router not included)",
"1 SonixNet ISP (IPoE-BYOR or PPPoE) Activation + Monthly",
"Customer setup required",
"NTT modem + ISP connection",
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
"Self-configuration of router (you provide your own)",
"Monthly: ¥6,000 | One-time: ¥22,800",
],
},
Gold: {
description: "Complete solution with v6plus router included",
tierDescription: "Recommended",
description: "Standard all-inclusive package with basic Wi-Fi",
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
features: [
"1 NTT Wireless Home Gateway Router (v6plus compatible)",
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly",
"Professional setup included",
"NTT modem + wireless router (rental)",
"ISP (IPoE) configured automatically within 24 hours",
"Basic wireless router included",
"Optional: TP-LINK RE650 range extender (¥500/month)",
"Monthly: ¥6,500 | One-time: ¥22,800",
],
},
Platinum: {
description: "Premium management for residences >50㎡",
tierDescription: "Premium",
description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
tierDescription: "Tailored set up with premier Wi-Fi management support",
features: [
"1 NTT Wireless Home Gateway Router Rental",
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly",
"NETGEAR INSIGHT Cloud Management System (¥500/month per router)",
"Professional WiFi setup consultation and additional router recommendations",
"NTT modem + Netgear INSIGHT Wi-Fi routers",
"Cloud management support for remote router management",
"Automatic updates and quicker support",
"Seamless wireless network setup",
"Monthly: ¥6,500 | One-time: ¥22,800",
"Cloud management: ¥500/month per router",
],
},
};

View File

@ -100,6 +100,53 @@ 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();
}
@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")
@ApiOperation({
summary: "Get invoice details by ID",
@ -182,34 +229,6 @@ export class InvoicesController {
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")
@HttpCode(HttpStatus.OK)
@ApiOperation({

View File

@ -270,7 +270,7 @@ export class InvoicesService {
overdue: 0,
totalAmount: 0,
unpaidAmount: 0,
currency: "USD",
currency: "JPY",
};
}
@ -284,7 +284,7 @@ export class InvoicesService {
unpaidAmount: invoices
.filter(i => ["Unpaid", "Overdue"].includes(i.status))
.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);
@ -387,12 +387,17 @@ export class InvoicesService {
*/
async getPaymentMethods(userId: string): Promise<PaymentMethodList> {
try {
this.logger.log(`Starting payment methods retrieval for user ${userId}`);
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
this.logger.error(`No WHMCS client mapping found for user ${userId}`);
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
const paymentMethods = await this.whmcsService.getPaymentMethods(
mapping.whmcsClientId,
@ -400,12 +405,15 @@ export class InvoicesService {
);
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;
} catch (error) {
this.logger.error(`Failed to get payment methods for user ${userId}`, {
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) {
@ -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
*/

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

View File

@ -218,7 +218,7 @@ export class SubscriptionsService {
completed: subscriptions.filter(s => s.status === "Completed").length,
totalMonthlyRevenue,
activeMonthlyRevenue,
currency: subscriptions[0]?.currency || "USD",
currency: subscriptions[0]?.currency || "JPY",
};
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);
}
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> {
return this.accountService.upsert(accountData);
}

View File

@ -28,6 +28,7 @@ interface SalesforceQueryResult {
interface SalesforceAccount {
Id: string;
Name: string;
WH_Account__c?: string;
}
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> {
if (!accountData.name?.trim()) throw new Error("Account name is required");

View File

@ -29,10 +29,36 @@ export class WhmcsPaymentService {
}
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
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
});
const methods = (response.paymethods?.paymethod || []).map(pm => {
const transformed = this.dataTransformer.transformPaymentMethod(pm);
this.logger.log(`Transformed payment method:`, {
original: pm,
transformed,
clientId,
userId
});
return transformed;
});
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
this.logger.log(`Final payment methods result for client ${clientId}:`, {
totalCount: result.totalCount,
methods: result.paymentMethods,
userId
});
await this.cacheService.setPaymentMethods(userId, result);
return result;
} catch (error) {
@ -167,6 +193,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)
*/

View File

@ -43,9 +43,9 @@ export class WhmcsDataTransformer {
id: Number(invoiceId),
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
currency: whmcsInvoice.currencycode || "USD",
currency: whmcsInvoice.currencycode || "JPY",
currencySymbol:
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"),
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
total: this.parseAmount(whmcsInvoice.total),
subtotal: this.parseAmount(whmcsInvoice.subtotal),
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
@ -92,7 +92,7 @@ export class WhmcsDataTransformer {
status: this.normalizeProductStatus(whmcsProduct.status),
nextDue: this.formatDate(whmcsProduct.nextduedate),
amount: this.getProductAmount(whmcsProduct),
currency: whmcsProduct.currencycode || "USD",
currency: whmcsProduct.currencycode || "JPY",
registrationDate:
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
@ -346,7 +346,7 @@ export class WhmcsDataTransformer {
USD: "$",
EUR: "€",
GBP: "£",
JPY: "¥",
JPY: "",
CAD: "C$",
AUD: "A$",
CNY: "¥",
@ -378,7 +378,7 @@ export class WhmcsDataTransformer {
NZD: "NZ$",
};
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "";
}
/**

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 { Label } from "@/components/ui/label";
import { useAuthStore } from "@/lib/auth/store";
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react";
const signupSchema = z
.object({
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(),
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"],
});
// Step 1: Customer Number Validation Schema
const step1Schema = z.object({
sfNumber: z.string().min(1, "Customer Number is required"),
});
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() {
const router = useRouter();
const { signup, isLoading } = useAuthStore();
const [currentStep, setCurrentStep] = useState(1);
const [error, setError] = useState<string | null>(null);
const [validationStatus, setValidationStatus] = useState<{
sfNumberValid: boolean;
whAccountValid: boolean;
sfAccountId?: string;
} | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
// Step 1 Form
const step1Form = useForm<Step1Form>({
resolver: zodResolver(step1Schema),
});
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 {
setError(null);
await signup({
email: data.email,
password: data.password,
firstName: data.firstName,
lastName: data.lastName,
setValidationStatus(null);
// Call backend to validate SF number and WH Account field
const response = await fetch("/api/auth/validate-signup", {
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,
phone: data.phone,
sfNumber: data.sfNumber,
address:
data.addressLine1 || data.city || data.state || data.postalCode || data.country
? {
line1: data.addressLine1 || "",
line2: data.addressLine2 || undefined,
city: data.city || "",
state: data.state || "",
postalCode: data.postalCode || "",
country: data.country || "",
}
: undefined,
nationality: data.nationality || undefined,
dateOfBirth: data.dateOfBirth || undefined,
gender: data.gender || undefined,
addressLine1: data.addressLine1,
addressLine2: data.addressLine2,
city: data.city,
state: data.state,
postalCode: data.postalCode,
country: data.country,
nationality: data.nationality,
dateOfBirth: data.dateOfBirth,
gender: data.gender,
};
await signup({
email: signupData.email,
password: signupData.password,
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");
} catch (err) {
setError(err instanceof Error ? err.message : "Signup failed");
}
};
return (
<AuthLayout
title="Create your account"
subtitle="Join Assist Solutions and manage your services"
>
<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>
)}
const goBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
setError(null);
}
};
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="firstName">First name</Label>
<Input
{...register("firstName")}
id="firstName"
type="text"
autoComplete="given-name"
className="mt-1"
placeholder="John"
/>
{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"
const renderStepIndicator = () => (
<div className="flex items-center justify-center mb-8">
<div className="flex items-center space-x-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
currentStep >= step
? "bg-blue-600 text-white"
: "bg-gray-200 text-gray-600"
}`}
>
<option value="">Select</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<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>
{step}
</div>
{step < 3 && (
<div
className={`w-12 h-0.5 mx-2 ${
currentStep > step ? "bg-blue-600" : "bg-gray-200"
}`}
/>
)}
</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 className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="email">Email address</Label>
<Input
{...register("email")}
id="email"
type="email"
autoComplete="email"
className="mt-1"
placeholder="john@example.com"
/>
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
</div>
<div>
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
<Input
{...register("confirmEmail")}
id="confirmEmail"
type="email"
autoComplete="email"
className="mt-1"
placeholder="john@example.com"
/>
{errors.confirmEmail && (
<p className="mt-1 text-sm text-red-600">{errors.confirmEmail.message}</p>
)}
</div>
)}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Validating..." : "Continue"}
</Button>
</form>
);
const renderStep2 = () => (
<form onSubmit={step2Form.handleSubmit(onStep2Submit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="firstName">First name</Label>
<Input
{...step2Form.register("firstName")}
id="firstName"
type="text"
autoComplete="given-name"
className="mt-1"
placeholder="John"
/>
{step2Form.formState.errors.firstName && (
<p className="mt-1 text-sm text-red-600">
{step2Form.formState.errors.firstName.message}
</p>
)}
</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>
<Label htmlFor="company">Company (optional)</Label>
<Input
{...register("company")}
{...step3Form.register("company")}
id="company"
type="text"
autoComplete="organization"
className="mt-1"
placeholder="Acme Corp"
/>
{errors.company && <p className="mt-1 text-sm text-red-600">{errors.company.message}</p>}
</div>
<div>
<Label htmlFor="phone">Phone (optional)</Label>
<Label htmlFor="phone">Phone number</Label>
<Input
{...register("phone")}
{...step3Form.register("phone")}
id="phone"
type="tel"
autoComplete="tel"
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>}
</div>
<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
{step3Form.formState.errors.phone && (
<p className="mt-1 text-sm text-red-600">
{step3Form.formState.errors.phone.message}
</p>
</div>
<div>
<Label htmlFor="confirmPassword">Password (confirm)</Label>
<Input
{...register("confirmPassword")}
id="confirmPassword"
type="password"
autoComplete="new-password"
className="mt-1"
placeholder="Re-enter your password"
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
)}
</div>
)}
</div>
</div>
<div>
<Label htmlFor="addressLine1">Address Line 1</Label>
<Input
{...step3Form.register("addressLine1")}
id="addressLine1"
type="text"
autoComplete="address-line1"
className="mt-1"
placeholder="Street, number"
/>
{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>
<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"}
</Button>
</div>
</form>
);
<div className="text-center space-y-2">
<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>
return (
<AuthLayout
title="Create your account"
subtitle="Join Assist Solutions and manage your services"
>
{renderStepIndicator()}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
<div className="flex items-center space-x-2">
<XCircle className="w-5 h-5" />
<span>{error}</span>
</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>
</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>
);
}

View File

@ -131,74 +131,46 @@ export default function InternetPlansPage() {
<span className="font-medium">Available for: {eligibility}</span>
</div>
<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>
</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.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<InternetPlanCard key={plan.id} plan={plan} />
))}
</div>
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<InternetPlanCard key={plan.id} plan={plan} />
))}
</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">
<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 }) {
const isGold = plan.tier === "Gold";
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 (
<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">
{/* Header */}
<div className="flex items-center justify-between mb-4">
@ -260,30 +242,42 @@ function InternetPlanCard({ plan }: { plan: InternetPlan }) {
</div>
{/* Plan Details */}
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3>
<p className="text-gray-600 text-sm mb-4">{plan.tierDescription}</p>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{plan.name}
</h3>
<p className="text-gray-600 text-sm mb-4">
{plan.tierDescription || plan.description}
</p>
{/* Your Plan Includes */}
<div className="mb-6 flex-grow">
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
<ul className="space-y-2 text-sm text-gray-700">
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s Hikari
Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.offeringType?.includes("10G")
? "10Gbps"
: plan.offeringType?.includes("100M")
? "100Mbps"
: "1Gbps"}
) Installation + Monthly
</li>
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span>
{feature}
</li>
))}
{plan.features && plan.features.length > 0 ? (
plan.features.map((feature, index) => (
<li key={index} className="flex items-start">
<span className="text-green-600 mr-2"></span>
{feature}
</li>
))
) : (
<>
<li className="flex items-start">
<span className="text-green-600 mr-2"></span>1 NTT Optical Fiber (Flet&apos;s Hikari
Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
{plan.offeringType?.includes("10G")
? "10Gbps"
: plan.offeringType?.includes("100M")
? "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>
</div>

View File

@ -99,7 +99,7 @@ export default function CatalogPage() {
<FeatureCard
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
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
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);
return (
<div className="mb-12">
<div>
<div className="flex items-center gap-3 mb-6">
{icon}
<div>
@ -55,7 +55,7 @@ function PlanTypeSection({
</div>
{/* 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 => (
<PlanCard key={plan.id} plan={plan} isFamily={false} />
))}
@ -71,7 +71,7 @@ function PlanTypeSection({
You qualify!
</span>
</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 => (
<PlanCard key={plan.id} plan={plan} isFamily={true} />
))}
@ -84,7 +84,7 @@ function PlanTypeSection({
function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
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>
<div className="flex items-center gap-2 mb-1">
@ -131,6 +131,7 @@ export default function SimPlansPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasExistingSim, setHasExistingSim] = useState(false);
const [activeTab, setActiveTab] = useState<'data-voice' | 'data-only' | 'voice-only'>('data-voice');
useEffect(() => {
let mounted = true;
@ -209,9 +210,9 @@ export default function SimPlansPage() {
description="Choose your mobile plan with flexible options"
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
>
<div className="max-w-7xl mx-auto">
<div className="max-w-6xl mx-auto px-4">
{/* Navigation */}
<div className="mb-6">
<div className="mb-6 flex justify-center">
<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" />
Back to Services
@ -258,35 +259,97 @@ export default function SimPlansPage() {
</div>
)}
{/* Data + Voice Plans (Most Popular) */}
<PlanTypeSection
title="Data + Voice Plans"
description="Internet, calling, and SMS included"
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
{/* Tab Navigation */}
<div className="mb-8 flex justify-center">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
onClick={() => setActiveTab('data-voice')}
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 */}
<PlanTypeSection
title="Data Only Plans"
description="Internet access for tablets, laptops, and IoT devices"
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim}
/>
{/* Tab Content */}
<div className="min-h-[400px]">
{activeTab === 'data-voice' && (
<PlanTypeSection
title="Data + Voice Plans"
description="Internet, calling, and SMS included"
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
)}
{/* Voice Only Plans */}
<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}
/>
{activeTab === 'data-only' && (
<PlanTypeSection
title="Data Only Plans"
description="Internet access for tablets, laptops, and IoT devices"
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
plans={plansByType.DataOnly}
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 */}
<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">
All SIM Plans Include
</h3>
@ -323,7 +386,7 @@ export default function SimPlansPage() {
</div>
{/* 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" />
<div className="text-sm">
<div className="font-medium text-blue-900 mb-1">Getting Started</div>

View File

@ -104,96 +104,112 @@ export default function VpnPlansPage() {
</div>
<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">
Secure VPN router rental service for business and personal use with enterprise-grade
security.
Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{vpnPlans.map(plan => {
const activationFee = getActivationFeeForRegion();
{/* Available Plans Section */}
{vpnPlans.length > 0 ? (
<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 (
<AnimatedCard key={plan.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<ShieldCheckIcon className="h-6 w-6 text-green-600" />
<div>
<h3 className="font-bold text-lg">{plan.name}</h3>
<p className="text-sm text-gray-600">VPN Router Rental</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{vpnPlans.map(plan => {
const activationFee = getActivationFeeForRegion(plan.region);
return (
<AnimatedCard key={plan.id} className="p-6 border-2 border-blue-200 hover:border-blue-300 transition-colors">
<div className="text-center mb-4">
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
</div>
</div>
</div>
<div className="mb-4">
<div className="flex items-baseline gap-1">
<CurrencyYenIcon className="h-4 w-4 text-gray-600" />
<span className="text-2xl font-bold text-gray-900">
{plan.monthlyPrice?.toLocaleString()}
</span>
<span className="text-gray-600">/month</span>
</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 className="mb-4 text-center">
<div className="flex items-baseline justify-center gap-1">
<CurrencyYenIcon className="h-5 w-5 text-gray-600" />
<span className="text-3xl font-bold text-gray-900">
{plan.monthlyPrice?.toLocaleString()}
</span>
<span className="text-gray-600">/month</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 && (
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="text-sm font-medium text-orange-800">Setup Fee</div>
<div className="text-lg font-bold text-orange-600">
¥{activationFee.price.toLocaleString()} one-time
</div>
</div>
)}
{plan.features && plan.features.length > 0 && (
<div className="mb-6">
<h4 className="font-medium text-gray-900 mb-3">Features:</h4>
<ul className="text-sm text-gray-700 space-y-1">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-start">
<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">
Request Quote
</AnimatedButton>
</AnimatedCard>
);
})}
</div>
<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>
<AnimatedButton
href={`/catalog/vpn/configure?plan=${plan.sku}`}
className="w-full"
>
Configure Plan
</AnimatedButton>
</AnimatedCard>
);
})}
</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>
{/* 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>
</PageLayout>
);

View File

@ -3,9 +3,10 @@
import { useState, useEffect, useMemo, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
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 { AddressConfirmation } from "@/components/checkout/address-confirmation";
import { usePaymentMethods } from "@/hooks/useInvoices";
import {
InternetPlan,
@ -44,6 +45,9 @@ function CheckoutContent() {
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 type = params.get("type") || "internet";
// Map to backend expected values
@ -329,26 +333,110 @@ function CheckoutContent() {
<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>
<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>
{paymentMethodsLoading ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600 text-sm">Checking payment methods...</span>
</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 ? '✅' : '❌'} |
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
) ? '✅' : '❌'}
</div>
<div className="flex gap-4">
@ -373,7 +461,14 @@ function CheckoutContent() {
<button
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"
>
{submitting ? (
@ -402,6 +497,10 @@ function CheckoutContent() {
</span>
) : !addressConfirmed ? (
"📍 Complete Address to Continue"
) : paymentMethodsLoading ? (
"⏳ Verifying Payment Method..."
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
"💳 Add Payment Method to Continue"
) : (
"📋 Submit Order for Review"
)}

View File

@ -28,12 +28,7 @@ import {
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
} from "@heroicons/react/24/solid";
import { format } from "date-fns";
import {
StatCard,
QuickAction,
DashboardActivityItem,
AccountStatusCard,
} from "@/features/dashboard/components";
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
export default function DashboardPage() {
@ -117,18 +112,18 @@ export default function DashboardPage() {
<div className="flex items-center justify-between">
<div>
<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>
<p className="text-lg text-gray-600">Here&apos;s your account overview for today</p>
</div>
<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" />
Notifications
</button>
<Link
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" />
Order Services
@ -157,7 +152,7 @@ export default function DashboardPage() {
title="Recent Orders"
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
icon={ClipboardDocumentListIconSolid}
gradient="from-indigo-500 to-purple-500"
gradient="from-gray-500 to-gray-600"
href="/orders"
/>
<StatCard
@ -166,8 +161,8 @@ export default function DashboardPage() {
icon={CreditCardIconSolid}
gradient={
(summary?.stats?.unpaidInvoices ?? 0) > 0
? "from-red-500 to-pink-500"
: "from-green-500 to-emerald-500"
? "from-amber-500 to-orange-500"
: "from-gray-500 to-gray-600"
}
href="/billing/invoices"
/>
@ -177,8 +172,8 @@ export default function DashboardPage() {
icon={ChatBubbleLeftRightIconSolid}
gradient={
(summary?.stats?.openCases ?? 0) > 0
? "from-amber-500 to-orange-500"
: "from-green-500 to-emerald-500"
? "from-blue-500 to-cyan-500"
: "from-gray-500 to-gray-600"
}
href="/support/cases"
/>
@ -190,14 +185,14 @@ export default function DashboardPage() {
{/* Next Invoice Due - Enhanced */}
{summary?.nextInvoice && (
<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-shrink-0">
<CalendarDaysIcon className="h-8 w-8 text-white" />
</div>
<div className="ml-4">
<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
</p>
</div>
@ -225,7 +220,7 @@ export default function DashboardPage() {
<button
onClick={() => handlePayNow(summary.nextInvoice!.id)}
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 ? (
<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="flex items-center justify-between">
<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 className="p-6">
@ -314,8 +317,8 @@ export default function DashboardPage() {
title="Track Orders"
description="View and track your orders"
icon={ClipboardDocumentListIcon}
iconColor="text-indigo-600"
bgColor="bg-indigo-50"
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/subscriptions"
@ -330,38 +333,35 @@ export default function DashboardPage() {
title="Order Services"
description="Browse catalog and add services"
icon={PlusIcon}
iconColor="text-purple-600"
bgColor="bg-purple-50"
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/billing/invoices"
title="View Invoices"
description="Check your billing history"
icon={DocumentTextIcon}
iconColor="text-green-600"
bgColor="bg-green-50"
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/support/new"
title="Get Support"
description="Create a new support ticket"
icon={ChatBubbleLeftRightIcon}
iconColor="text-purple-600"
bgColor="bg-purple-50"
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
<QuickAction
href="/billing/payments"
title="Payment Methods"
description="Manage your payment options"
icon={CreditCardIcon}
iconColor="text-orange-600"
bgColor="bg-orange-50"
iconColor="text-blue-600"
bgColor="bg-blue-50"
/>
</div>
</div>
{/* Account Status */}
<AccountStatusCard />
</div>
</div>
</div>

View File

@ -1,16 +1,26 @@
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() {
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 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="flex justify-between items-center h-16">
<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">
<span className="text-white font-bold text-lg">AS</span>
</div>
<Logo size={40} />
<div>
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
<p className="text-xs text-gray-500">Customer Portal</p>
@ -32,83 +42,58 @@ export default function Home() {
</div>
</header>
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-100 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="inline-flex items-center bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium mb-4">
New Portal Available
</div>
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
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>
{/* Hero Section */}
<section className="relative py-20 overflow-hidden">
{/* Abstract background elements */}
<div className="absolute inset-0">
<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>
<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 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>
</div>
<div className="flex justify-center">
<a
href="#portal-access"
className="bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-colors font-semibold text-lg"
>
Get Started
</a>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
New Assist Solutions Customer Portal
</h1>
<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>
</section>
</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="text-center mb-16">
<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>
</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 */}
<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="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>
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Already have an account with us? Migrate to our new, improved portal to enjoy
enhanced features, better security, and a modern interface.
Migrate to our new portal and enjoy enhanced security with modern interface.
</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">
<Link
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
</Link>
@ -118,41 +103,20 @@ export default function Home() {
</div>
{/* 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="flex items-center justify-center w-16 h-16 bg-green-600 rounded-full mb-6 mx-auto">
<span className="text-white text-2xl">👤</span>
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
<UserIcon className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Already migrated or have a new portal account? Sign in to access your dashboard,
manage services, and view billing.
Sign in to access your dashboard and manage all your services efficiently.
</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">
<Link
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
</Link>
@ -162,43 +126,20 @@ export default function Home() {
</div>
{/* 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="flex items-center justify-center w-16 h-16 bg-purple-600 rounded-full mb-6 mx-auto">
<span className="text-white text-2xl"></span>
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
<SparklesIcon className="w-8 h-8 text-white" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
<p className="text-gray-600 mb-6 leading-relaxed">
Ready to get started with our services? Create your account to access our full
range of IT solutions.
Create your account and access our full range of IT solutions and services.
</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">
<Link
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
</Link>
@ -211,8 +152,8 @@ export default function Home() {
</section>
{/* Portal Features Section */}
<section className="py-16 bg-gray-50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2>
<p className="text-lg text-gray-600">
@ -221,28 +162,36 @@ export default function Home() {
</div>
<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="text-3xl mb-3">💳</div>
<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="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>
<p className="text-gray-600 text-sm">
View invoices, payment history, and manage billing
</p>
</div>
<div className="bg-white rounded-lg p-6 text-center">
<div className="text-3xl mb-3"></div>
<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="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>
<p className="text-gray-600 text-sm">Control and configure your active services</p>
</div>
<div className="bg-white rounded-lg p-6 text-center">
<div className="text-3xl mb-3">📞</div>
<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="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>
<p className="text-gray-600 text-sm">Create and track support requests</p>
</div>
<div className="bg-white rounded-lg p-6 text-center">
<div className="text-3xl mb-3">📊</div>
<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="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>
<p className="text-gray-600 text-sm">Monitor service usage and performance</p>
</div>
@ -250,79 +199,66 @@ export default function Home() {
</div>
</section>
{/* Contact Section */}
<section className="py-16 bg-gray-50">
{/* Support Section */}
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<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>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-xl">📞</span>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Contact Details Box */}
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
<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>
<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 className="text-center">
<div className="w-12 h-12 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-xl">💬</span>
</div>
<h3 className="text-lg font-semibold mb-2">Live Chat</h3>
<p className="text-gray-600 text-sm mb-2">Available 24/7</p>
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
Start Chat
</Link>
</div>
{/* Live Chat & Business Hours Box */}
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
<h3 className="text-xl font-semibold mb-6 text-gray-900">Live Chat & Business Hours</h3>
<div className="grid grid-cols-2 gap-6">
<div className="text-center">
<h4 className="font-semibold text-gray-900 mb-2">Live Chat</h4>
<p className="text-gray-600 text-sm mb-1">Available 24/7</p>
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
Start Chat
</Link>
</div>
<div className="text-center">
<div className="w-12 h-12 bg-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-xl"></span>
<div>
<h4 className="font-semibold text-gray-900 mb-3">Business Hours</h4>
<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>
<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>
</section>
{/* 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="flex flex-col md:flex-row justify-between items-center">
<div className="flex items-center space-x-3 mb-4 md:mb-0">
<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">
<div className="text-center">
<p className="text-gray-600 text-sm">
© 2025 Assist Solutions Corp. All Rights Reserved.
</p>
</div>

View File

@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store";
import { Logo } from "@/components/ui/logo";
import {
HomeIcon,
CreditCardIcon,
@ -33,6 +34,7 @@ interface NavigationItem {
href?: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
children?: NavigationChild[];
isLogout?: boolean;
}
const navigation = [
@ -66,13 +68,14 @@ const navigation = [
{ name: "Notifications", href: "/account/notifications" },
],
},
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
];
export function DashboardLayout({ children }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const [mounted, setMounted] = useState(false);
const { user, isAuthenticated, logout, checkAuth } = useAuthStore();
const { user, isAuthenticated, checkAuth } = useAuthStore();
const pathname = usePathname();
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
if (!mounted) {
return (
@ -156,10 +153,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{/* Main content */}
<div className="flex flex-col w-0 flex-1 overflow-hidden">
{/* 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
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)}
>
<Bars3Icon className="h-6 w-6" />
@ -168,13 +165,22 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
<div className="flex-1 flex">
<div className="w-full flex md:ml-0">
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
<div className="flex items-center h-16">
<h1 className="text-lg font-semibold text-gray-900">Assist Solutions Portal</h1>
</div>
<div className="flex items-center h-16"></div>
</div>
</div>
</div>
<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 */}
<button
type="button"
@ -182,29 +188,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
>
<BellIcon className="h-6 w-6" />
</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>
@ -228,13 +211,11 @@ function DesktopSidebar({
toggleExpanded: (name: string) => void;
}) {
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 items-center flex-shrink-0 px-4">
<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">
<span className="text-white font-bold text-sm">AS</span>
</div>
<Logo size={32} />
<span className="text-lg font-semibold text-gray-900">Portal</span>
</div>
</div>
@ -266,13 +247,11 @@ function MobileSidebar({
toggleExpanded: (name: string) => void;
}) {
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 items-center flex-shrink-0 px-4">
<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">
<span className="text-white font-bold text-sm">AS</span>
</div>
<Logo size={32} />
<span className="text-lg font-semibold text-gray-900">Portal</span>
</div>
</div>
@ -303,6 +282,9 @@ function NavigationItem({
isExpanded: boolean;
toggleExpanded: (name: string) => void;
}) {
const { logout } = useAuthStore();
const router = useRouter();
const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
@ -310,6 +292,12 @@ function NavigationItem({
? pathname === item.href
: false;
const handleLogout = () => {
void logout().then(() => {
router.push("/");
});
};
if (hasChildren) {
return (
<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 (
<Link
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`);
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 15 * 60 * 1000, // 15 minutes
staleTime: 1 * 60 * 1000, // Reduced to 1 minute for better refresh
gcTime: 5 * 60 * 1000, // Reduced to 5 minutes
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: "$",
EUR: "€",
GBP: "£",
JPY: "¥",
JPY: "",
CAD: "C$",
AUD: "A$",
CNY: "¥",
@ -85,7 +85,7 @@ export function getCurrencySymbol(currencyCode: string): string {
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

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

View File

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

View File

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

View File

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

9
pnpm-lock.yaml generated
View File

@ -15,6 +15,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3.3.1
version: 3.3.1
'@eslint/js':
specifier: ^9.34.0
version: 9.34.0
'@types/node':
specifier: ^24.3.0
version: 24.3.0
@ -548,6 +551,10 @@ packages:
resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
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':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -5288,6 +5295,8 @@ snapshots:
'@eslint/js@9.33.0': {}
'@eslint/js@9.34.0': {}
'@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.3.5':

View File

@ -39,13 +39,13 @@ start_services() {
fi
# 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
log "⏳ Waiting for database to be ready..."
timeout=30
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!"
break
fi
@ -65,7 +65,7 @@ start_services() {
# Start with admin tools
start_with_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 "🔗 Redis Commander: http://localhost:8081"
@ -74,19 +74,19 @@ start_with_tools() {
# Stop services
stop_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"
}
# Show status
show_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() {
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)
@ -94,7 +94,7 @@ start_apps() {
log "🚀 Starting development services and applications..."
# 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
fi
@ -121,7 +121,7 @@ start_apps() {
reset_env() {
log "🔄 Resetting development environment..."
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
log "✅ Development environment reset"
}
@ -130,7 +130,7 @@ reset_env() {
migrate_db() {
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"
fi