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:
parent
1762d67e3f
commit
1640fae457
@ -10,6 +10,7 @@ import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
|
|||||||
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
import { ResetPasswordDto } from "./dto/reset-password.dto";
|
||||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||||
|
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
||||||
import { Public } from "./decorators/public.decorator";
|
import { Public } from "./decorators/public.decorator";
|
||||||
|
|
||||||
@ApiTags("auth")
|
@ApiTags("auth")
|
||||||
@ -17,6 +18,27 @@ import { Public } from "./decorators/public.decorator";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post("validate-signup")
|
||||||
|
@UseGuards(AuthThrottleGuard)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP
|
||||||
|
@ApiOperation({ summary: "Validate customer number for signup" })
|
||||||
|
@ApiResponse({ status: 200, description: "Validation successful" })
|
||||||
|
@ApiResponse({ status: 409, description: "Customer already has account" })
|
||||||
|
@ApiResponse({ status: 400, description: "Customer number not found" })
|
||||||
|
@ApiResponse({ status: 429, description: "Too many validation attempts" })
|
||||||
|
async validateSignup(@Body() validateDto: ValidateSignupDto, @Req() req: Request) {
|
||||||
|
return this.authService.validateSignup(validateDto, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get("health-check")
|
||||||
|
@ApiOperation({ summary: "Check auth service health and integrations" })
|
||||||
|
@ApiResponse({ status: 200, description: "Health check results" })
|
||||||
|
async healthCheck() {
|
||||||
|
return this.authService.healthCheck();
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post("signup")
|
@Post("signup")
|
||||||
@UseGuards(AuthThrottleGuard)
|
@UseGuards(AuthThrottleGuard)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { AuditService, AuditAction } from "../common/audit/audit.service";
|
|||||||
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
import { TokenBlacklistService } from "./services/token-blacklist.service";
|
||||||
import { SignupDto } from "./dto/signup.dto";
|
import { SignupDto } from "./dto/signup.dto";
|
||||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||||
|
import { ValidateSignupDto } from "./dto/validate-signup.dto";
|
||||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||||
import { getErrorMessage } from "../common/utils/error.util";
|
import { getErrorMessage } from "../common/utils/error.util";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
@ -43,6 +44,134 @@ export class AuthService {
|
|||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async healthCheck() {
|
||||||
|
const health = {
|
||||||
|
database: false,
|
||||||
|
whmcs: false,
|
||||||
|
salesforce: false,
|
||||||
|
whmcsConfig: {
|
||||||
|
baseUrl: !!this.configService.get("WHMCS_BASE_URL"),
|
||||||
|
identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"),
|
||||||
|
secret: !!this.configService.get("WHMCS_API_SECRET"),
|
||||||
|
},
|
||||||
|
salesforceConfig: {
|
||||||
|
connected: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
try {
|
||||||
|
await this.usersService.findByEmail("health-check@test.com");
|
||||||
|
health.database = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug("Database health check failed", { error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check WHMCS
|
||||||
|
try {
|
||||||
|
// Try a simple WHMCS API call (this will fail if not configured)
|
||||||
|
await this.whmcsService.getProducts();
|
||||||
|
health.whmcs = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Salesforce
|
||||||
|
try {
|
||||||
|
health.salesforceConfig.connected = this.salesforceService.healthCheck();
|
||||||
|
health.salesforce = health.salesforceConfig.connected;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded",
|
||||||
|
services: health,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateSignup(validateData: ValidateSignupDto, request?: Request) {
|
||||||
|
const { sfNumber } = validateData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Check if SF number exists in Salesforce
|
||||||
|
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber);
|
||||||
|
if (!sfAccount) {
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber, reason: "SF number not found" },
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
"Customer number not found in Salesforce"
|
||||||
|
);
|
||||||
|
throw new BadRequestException("Customer number not found in Salesforce");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if SF account already has a mapping (already registered)
|
||||||
|
const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id);
|
||||||
|
if (existingMapping) {
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" },
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
"Customer number already registered"
|
||||||
|
);
|
||||||
|
throw new ConflictException("You already have an account. Please use the login page to access your existing account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check WH_Account__c field in Salesforce
|
||||||
|
const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id);
|
||||||
|
if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") {
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber, sfAccountId: sfAccount.id, whAccount: accountDetails.WH_Account__c, reason: "WH Account not empty" },
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
"Account already has WHMCS integration"
|
||||||
|
);
|
||||||
|
throw new ConflictException("You already have an account. Please use the login page to access your existing account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful validation
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber, sfAccountId: sfAccount.id, step: "validation" },
|
||||||
|
request,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
sfAccountId: sfAccount.id,
|
||||||
|
message: "Customer number validated successfully"
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Re-throw known exceptions
|
||||||
|
if (error instanceof BadRequestException || error instanceof ConflictException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log unexpected errors
|
||||||
|
await this.auditService.logAuthEvent(
|
||||||
|
AuditAction.SIGNUP,
|
||||||
|
undefined,
|
||||||
|
{ sfNumber, error: getErrorMessage(error) },
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
getErrorMessage(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.error("Signup validation error", { error: getErrorMessage(error) });
|
||||||
|
throw new BadRequestException("Validation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async signup(signupData: SignupDto, request?: Request) {
|
async signup(signupData: SignupDto, request?: Request) {
|
||||||
const {
|
const {
|
||||||
email,
|
email,
|
||||||
@ -106,36 +235,91 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. Create client in WHMCS
|
// 2. Create client in WHMCS
|
||||||
// Prepare WHMCS custom fields (IDs configurable via env)
|
let whmcsClient: { clientId: number };
|
||||||
const customerNumberFieldId = this.configService.get<string>(
|
try {
|
||||||
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
// Prepare WHMCS custom fields (IDs configurable via env)
|
||||||
"198"
|
const customerNumberFieldId = this.configService.get<string>(
|
||||||
);
|
"WHMCS_CUSTOMER_NUMBER_FIELD_ID",
|
||||||
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
"198"
|
||||||
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
);
|
||||||
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
const dobFieldId = this.configService.get<string>("WHMCS_DOB_FIELD_ID");
|
||||||
|
const genderFieldId = this.configService.get<string>("WHMCS_GENDER_FIELD_ID");
|
||||||
|
const nationalityFieldId = this.configService.get<string>("WHMCS_NATIONALITY_FIELD_ID");
|
||||||
|
|
||||||
const customfields: Record<string, string> = {};
|
const customfields: Record<string, string> = {};
|
||||||
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber;
|
||||||
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth;
|
||||||
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
if (genderFieldId && gender) customfields[genderFieldId] = gender;
|
||||||
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality;
|
||||||
|
|
||||||
const whmcsClient: { clientId: number } = await this.whmcsService.addClient({
|
this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber });
|
||||||
firstname: firstName,
|
|
||||||
lastname: lastName,
|
// Validate required WHMCS fields
|
||||||
email,
|
if (!address?.line1 || !address?.city || !address?.state || !address?.postalCode || !address?.country) {
|
||||||
companyname: company || "",
|
throw new BadRequestException("Complete address information is required for billing account creation");
|
||||||
phonenumber: phone || "",
|
}
|
||||||
address1: address.line1,
|
|
||||||
address2: address.line2 || "",
|
if (!phone) {
|
||||||
city: address.city,
|
throw new BadRequestException("Phone number is required for billing account creation");
|
||||||
state: address.state,
|
}
|
||||||
postcode: address.postalCode,
|
|
||||||
country: address.country,
|
this.logger.log("WHMCS client data", {
|
||||||
password2: password, // WHMCS requires plain password for new clients
|
email,
|
||||||
customfields,
|
firstName,
|
||||||
});
|
lastName,
|
||||||
|
address: address,
|
||||||
|
phone,
|
||||||
|
country: address.country,
|
||||||
|
});
|
||||||
|
|
||||||
|
whmcsClient = await this.whmcsService.addClient({
|
||||||
|
firstname: firstName,
|
||||||
|
lastname: lastName,
|
||||||
|
email,
|
||||||
|
companyname: company || "",
|
||||||
|
phonenumber: phone,
|
||||||
|
address1: address.line1,
|
||||||
|
address2: address.line2 || "",
|
||||||
|
city: address.city,
|
||||||
|
state: address.state,
|
||||||
|
postcode: address.postalCode,
|
||||||
|
country: address.country,
|
||||||
|
password2: password, // WHMCS requires plain password for new clients
|
||||||
|
customfields,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("WHMCS client created successfully", {
|
||||||
|
clientId: whmcsClient.clientId,
|
||||||
|
email
|
||||||
|
});
|
||||||
|
} catch (whmcsError) {
|
||||||
|
this.logger.error("Failed to create WHMCS client", {
|
||||||
|
error: getErrorMessage(whmcsError),
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rollback: Delete the portal user since WHMCS creation failed
|
||||||
|
try {
|
||||||
|
// Note: We should add a delete method to UsersService, but for now use direct approach
|
||||||
|
this.logger.warn("WHMCS creation failed, user account created but not fully integrated", {
|
||||||
|
userId: user.id,
|
||||||
|
email,
|
||||||
|
whmcsError: getErrorMessage(whmcsError),
|
||||||
|
});
|
||||||
|
} catch (rollbackError) {
|
||||||
|
this.logger.error("Failed to log rollback information", {
|
||||||
|
userId: user.id,
|
||||||
|
email,
|
||||||
|
rollbackError: getErrorMessage(rollbackError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Failed to create billing account: ${getErrorMessage(whmcsError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Store ID mappings
|
// 3. Store ID mappings
|
||||||
await this.mappingsService.createMapping({
|
await this.mappingsService.createMapping({
|
||||||
@ -144,11 +328,15 @@ export class AuthService {
|
|||||||
sfAccountId: sfAccount.id,
|
sfAccountId: sfAccount.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 4. Update WH_Account__c field in Salesforce
|
||||||
|
const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`;
|
||||||
|
await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue);
|
||||||
|
|
||||||
// Log successful signup
|
// Log successful signup
|
||||||
await this.auditService.logAuthEvent(
|
await this.auditService.logAuthEvent(
|
||||||
AuditAction.SIGNUP,
|
AuditAction.SIGNUP,
|
||||||
user.id,
|
user.id,
|
||||||
{ email, whmcsClientId: whmcsClient.clientId },
|
{ email, whmcsClientId: whmcsClient.clientId, whAccountValue },
|
||||||
request,
|
request,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|||||||
12
apps/bff/src/auth/dto/validate-signup.dto.ts
Normal file
12
apps/bff/src/auth/dto/validate-signup.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -231,31 +231,36 @@ export class InternetCatalogService extends BaseCatalogService {
|
|||||||
{ description: string; tierDescription: string; features: string[] }
|
{ description: string; tierDescription: string; features: string[] }
|
||||||
> = {
|
> = {
|
||||||
Silver: {
|
Silver: {
|
||||||
description: "Basic setup - bring your own router",
|
description: "Simple package with broadband-modem and ISP only",
|
||||||
tierDescription: "Basic",
|
tierDescription: "Simple package with broadband-modem and ISP only",
|
||||||
features: [
|
features: [
|
||||||
"1 NTT Modem (router not included)",
|
"NTT modem + ISP connection",
|
||||||
"1 SonixNet ISP (IPoE-BYOR or PPPoE) Activation + Monthly",
|
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
|
||||||
"Customer setup required",
|
"Self-configuration of router (you provide your own)",
|
||||||
|
"Monthly: ¥6,000 | One-time: ¥22,800",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Gold: {
|
Gold: {
|
||||||
description: "Complete solution with v6plus router included",
|
description: "Standard all-inclusive package with basic Wi-Fi",
|
||||||
tierDescription: "Recommended",
|
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
|
||||||
features: [
|
features: [
|
||||||
"1 NTT Wireless Home Gateway Router (v6plus compatible)",
|
"NTT modem + wireless router (rental)",
|
||||||
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly",
|
"ISP (IPoE) configured automatically within 24 hours",
|
||||||
"Professional setup included",
|
"Basic wireless router included",
|
||||||
|
"Optional: TP-LINK RE650 range extender (¥500/month)",
|
||||||
|
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Platinum: {
|
Platinum: {
|
||||||
description: "Premium management for residences >50㎡",
|
description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
|
||||||
tierDescription: "Premium",
|
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
||||||
features: [
|
features: [
|
||||||
"1 NTT Wireless Home Gateway Router Rental",
|
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||||
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly",
|
"Cloud management support for remote router management",
|
||||||
"NETGEAR INSIGHT Cloud Management System (¥500/month per router)",
|
"Automatic updates and quicker support",
|
||||||
"Professional WiFi setup consultation and additional router recommendations",
|
"Seamless wireless network setup",
|
||||||
|
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||||
|
"Cloud management: ¥500/month per router",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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")
|
@Get(":id")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Get invoice details by ID",
|
summary: "Get invoice details by ID",
|
||||||
@ -182,34 +229,6 @@ export class InvoicesController {
|
|||||||
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view");
|
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("payment-methods")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Get user payment methods",
|
|
||||||
description: "Retrieves all saved payment methods for the authenticated user",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: "List of payment methods",
|
|
||||||
type: Object, // Would be PaymentMethodList if we had proper DTO decorators
|
|
||||||
})
|
|
||||||
async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
|
|
||||||
return this.invoicesService.getPaymentMethods(req.user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("payment-gateways")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Get available payment gateways",
|
|
||||||
description: "Retrieves all active payment gateways available for payments",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: "List of payment gateways",
|
|
||||||
type: Object, // Would be PaymentGatewayList if we had proper DTO decorators
|
|
||||||
})
|
|
||||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
|
||||||
return this.invoicesService.getPaymentGateways();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post(":id/payment-link")
|
@Post(":id/payment-link")
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@ -270,7 +270,7 @@ export class InvoicesService {
|
|||||||
overdue: 0,
|
overdue: 0,
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
unpaidAmount: 0,
|
unpaidAmount: 0,
|
||||||
currency: "USD",
|
currency: "JPY",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,7 +284,7 @@ export class InvoicesService {
|
|||||||
unpaidAmount: invoices
|
unpaidAmount: invoices
|
||||||
.filter(i => ["Unpaid", "Overdue"].includes(i.status))
|
.filter(i => ["Unpaid", "Overdue"].includes(i.status))
|
||||||
.reduce((sum, i) => sum + i.total, 0),
|
.reduce((sum, i) => sum + i.total, 0),
|
||||||
currency: invoices[0]?.currency || "USD",
|
currency: invoices[0]?.currency || "JPY",
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Generated invoice stats for user ${userId}`, stats);
|
this.logger.log(`Generated invoice stats for user ${userId}`, stats);
|
||||||
@ -387,12 +387,17 @@ export class InvoicesService {
|
|||||||
*/
|
*/
|
||||||
async getPaymentMethods(userId: string): Promise<PaymentMethodList> {
|
async getPaymentMethods(userId: string): Promise<PaymentMethodList> {
|
||||||
try {
|
try {
|
||||||
|
this.logger.log(`Starting payment methods retrieval for user ${userId}`);
|
||||||
|
|
||||||
// Get WHMCS client ID from user mapping
|
// Get WHMCS client ID from user mapping
|
||||||
const mapping = await this.mappingsService.findByUserId(userId);
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
|
this.logger.error(`No WHMCS client mapping found for user ${userId}`);
|
||||||
throw new NotFoundException("WHMCS client mapping not found");
|
throw new NotFoundException("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Found WHMCS client ID ${mapping.whmcsClientId} for user ${userId}`);
|
||||||
|
|
||||||
// Fetch payment methods from WHMCS
|
// Fetch payment methods from WHMCS
|
||||||
const paymentMethods = await this.whmcsService.getPaymentMethods(
|
const paymentMethods = await this.whmcsService.getPaymentMethods(
|
||||||
mapping.whmcsClientId,
|
mapping.whmcsClientId,
|
||||||
@ -400,12 +405,15 @@ export class InvoicesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId}`
|
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId} (client ${mapping.whmcsClientId})`
|
||||||
);
|
);
|
||||||
return paymentMethods;
|
return paymentMethods;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get payment methods for user ${userId}`, {
|
this.logger.error(`Failed to get payment methods for user ${userId}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
|
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
@ -416,6 +424,29 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate payment methods cache for a user
|
||||||
|
*/
|
||||||
|
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get WHMCS client ID from user mapping
|
||||||
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (!mapping?.whmcsClientId) {
|
||||||
|
throw new NotFoundException("WHMCS client mapping not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate WHMCS payment methods cache
|
||||||
|
await this.whmcsService.invalidatePaymentMethodsCache(userId);
|
||||||
|
|
||||||
|
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw new Error(`Failed to invalidate payment methods cache: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available payment gateways
|
* Get available payment gateways
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -89,6 +89,59 @@ export class MappingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find mapping by Salesforce Account ID
|
||||||
|
*/
|
||||||
|
async findBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
|
||||||
|
try {
|
||||||
|
// Validate SF Account ID
|
||||||
|
if (!sfAccountId) {
|
||||||
|
throw new BadRequestException("Salesforce Account ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try cache first (check all cached mappings)
|
||||||
|
const allCached = await this.getAllMappingsFromDb();
|
||||||
|
const cachedMapping = allCached.find((m: UserIdMapping) => m.sfAccountId === sfAccountId);
|
||||||
|
if (cachedMapping) {
|
||||||
|
this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);
|
||||||
|
return cachedMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from database
|
||||||
|
const dbMapping = await this.prisma.idMapping.findFirst({
|
||||||
|
where: { sfAccountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbMapping) {
|
||||||
|
this.logger.debug(`No mapping found for SF account ${sfAccountId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping: UserIdMapping = {
|
||||||
|
userId: dbMapping.userId,
|
||||||
|
whmcsClientId: dbMapping.whmcsClientId,
|
||||||
|
sfAccountId: dbMapping.sfAccountId || undefined,
|
||||||
|
createdAt: dbMapping.createdAt,
|
||||||
|
updatedAt: dbMapping.updatedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
await this.cacheService.setMapping(mapping);
|
||||||
|
|
||||||
|
this.logger.debug(`Found mapping for SF account ${sfAccountId}`, {
|
||||||
|
userId: mapping.userId,
|
||||||
|
whmcsClientId: mapping.whmcsClientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find mapping by user ID
|
* Find mapping by user ID
|
||||||
*/
|
*/
|
||||||
@ -193,57 +246,7 @@ export class MappingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find mapping by Salesforce account ID
|
|
||||||
*/
|
|
||||||
async findBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
|
|
||||||
try {
|
|
||||||
// Validate Salesforce account ID
|
|
||||||
if (!sfAccountId) {
|
|
||||||
throw new BadRequestException("Salesforce account ID is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try cache first
|
|
||||||
const cached = await this.cacheService.getBySfAccountId(sfAccountId);
|
|
||||||
if (cached) {
|
|
||||||
this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch from database
|
|
||||||
const dbMapping = await this.prisma.idMapping.findFirst({
|
|
||||||
where: { sfAccountId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dbMapping) {
|
|
||||||
this.logger.debug(`No mapping found for SF account ${sfAccountId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapping: UserIdMapping = {
|
|
||||||
userId: dbMapping.userId,
|
|
||||||
whmcsClientId: dbMapping.whmcsClientId,
|
|
||||||
sfAccountId: dbMapping.sfAccountId || undefined,
|
|
||||||
createdAt: dbMapping.createdAt,
|
|
||||||
updatedAt: dbMapping.updatedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
await this.cacheService.setMapping(mapping);
|
|
||||||
|
|
||||||
this.logger.debug(`Found mapping for SF account ${sfAccountId}`, {
|
|
||||||
userId: mapping.userId,
|
|
||||||
whmcsClientId: mapping.whmcsClientId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapping;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing mapping
|
* Update an existing mapping
|
||||||
|
|||||||
@ -218,7 +218,7 @@ export class SubscriptionsService {
|
|||||||
completed: subscriptions.filter(s => s.status === "Completed").length,
|
completed: subscriptions.filter(s => s.status === "Completed").length,
|
||||||
totalMonthlyRevenue,
|
totalMonthlyRevenue,
|
||||||
activeMonthlyRevenue,
|
activeMonthlyRevenue,
|
||||||
currency: subscriptions[0]?.currency || "USD",
|
currency: subscriptions[0]?.currency || "JPY",
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Generated subscription stats for user ${userId}`, {
|
this.logger.log(`Generated subscription stats for user ${userId}`, {
|
||||||
|
|||||||
@ -62,6 +62,14 @@ export class SalesforceService implements OnModuleInit {
|
|||||||
return this.accountService.findByCustomerNumber(customerNumber);
|
return this.accountService.findByCustomerNumber(customerNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> {
|
||||||
|
return this.accountService.getAccountDetails(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWhAccount(accountId: string, whAccountValue: string): Promise<void> {
|
||||||
|
return this.accountService.updateWhAccount(accountId, whAccountValue);
|
||||||
|
}
|
||||||
|
|
||||||
async upsertAccount(accountData: AccountData): Promise<UpsertResult> {
|
async upsertAccount(accountData: AccountData): Promise<UpsertResult> {
|
||||||
return this.accountService.upsert(accountData);
|
return this.accountService.upsert(accountData);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ interface SalesforceQueryResult {
|
|||||||
interface SalesforceAccount {
|
interface SalesforceAccount {
|
||||||
Id: string;
|
Id: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
|
WH_Account__c?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SalesforceCreateResult {
|
interface SalesforceCreateResult {
|
||||||
@ -58,6 +59,61 @@ export class SalesforceAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> {
|
||||||
|
if (!accountId?.trim()) throw new Error("Account ID is required");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await this.connection.query(
|
||||||
|
`SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'`
|
||||||
|
)) as SalesforceQueryResult;
|
||||||
|
|
||||||
|
if (result.totalSize === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = result.records[0];
|
||||||
|
return {
|
||||||
|
id: record.Id,
|
||||||
|
Name: record.Name,
|
||||||
|
WH_Account__c: record.WH_Account__c || undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to get account details", {
|
||||||
|
accountId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw new Error("Failed to get account details");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWhAccount(accountId: string, whAccountValue: string): Promise<void> {
|
||||||
|
if (!accountId?.trim()) throw new Error("Account ID is required");
|
||||||
|
if (!whAccountValue?.trim()) throw new Error("WH Account value is required");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sobject = this.connection.sobject("Account") as unknown as {
|
||||||
|
update: (data: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
await sobject.update({
|
||||||
|
Id: accountId.trim(),
|
||||||
|
WH_Account__c: whAccountValue.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Updated WH Account field", {
|
||||||
|
accountId,
|
||||||
|
whAccountValue,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to update WH Account field", {
|
||||||
|
accountId,
|
||||||
|
whAccountValue,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw new Error("Failed to update WH Account field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async upsert(accountData: AccountData): Promise<UpsertResult> {
|
async upsert(accountData: AccountData): Promise<UpsertResult> {
|
||||||
if (!accountData.name?.trim()) throw new Error("Account name is required");
|
if (!accountData.name?.trim()) throw new Error("Account name is required");
|
||||||
|
|
||||||
|
|||||||
@ -29,10 +29,36 @@ export class WhmcsPaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.connectionService.getPayMethods({ clientid: clientId });
|
const response = await this.connectionService.getPayMethods({ clientid: clientId });
|
||||||
const methods = (response.paymethods?.paymethod || []).map(pm =>
|
|
||||||
this.dataTransformer.transformPaymentMethod(pm)
|
// Debug logging to understand what WHMCS returns
|
||||||
);
|
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 };
|
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);
|
await this.cacheService.setPaymentMethods(userId, result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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)
|
* Transform product data (delegate to transformer)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -43,9 +43,9 @@ export class WhmcsDataTransformer {
|
|||||||
id: Number(invoiceId),
|
id: Number(invoiceId),
|
||||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||||
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
|
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||||
currency: whmcsInvoice.currencycode || "USD",
|
currency: whmcsInvoice.currencycode || "JPY",
|
||||||
currencySymbol:
|
currencySymbol:
|
||||||
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"),
|
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"),
|
||||||
total: this.parseAmount(whmcsInvoice.total),
|
total: this.parseAmount(whmcsInvoice.total),
|
||||||
subtotal: this.parseAmount(whmcsInvoice.subtotal),
|
subtotal: this.parseAmount(whmcsInvoice.subtotal),
|
||||||
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
|
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
|
||||||
@ -92,7 +92,7 @@ export class WhmcsDataTransformer {
|
|||||||
status: this.normalizeProductStatus(whmcsProduct.status),
|
status: this.normalizeProductStatus(whmcsProduct.status),
|
||||||
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
||||||
amount: this.getProductAmount(whmcsProduct),
|
amount: this.getProductAmount(whmcsProduct),
|
||||||
currency: whmcsProduct.currencycode || "USD",
|
currency: whmcsProduct.currencycode || "JPY",
|
||||||
|
|
||||||
registrationDate:
|
registrationDate:
|
||||||
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
|
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
|
||||||
@ -346,7 +346,7 @@ export class WhmcsDataTransformer {
|
|||||||
USD: "$",
|
USD: "$",
|
||||||
EUR: "€",
|
EUR: "€",
|
||||||
GBP: "£",
|
GBP: "£",
|
||||||
JPY: "¥",
|
JPY: "¥",
|
||||||
CAD: "C$",
|
CAD: "C$",
|
||||||
AUD: "A$",
|
AUD: "A$",
|
||||||
CNY: "¥",
|
CNY: "¥",
|
||||||
@ -378,7 +378,7 @@ export class WhmcsDataTransformer {
|
|||||||
NZD: "NZ$",
|
NZD: "NZ$",
|
||||||
};
|
};
|
||||||
|
|
||||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
|
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "¥";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
31
apps/portal/src/app/api/auth/login/route.ts
Normal file
31
apps/portal/src/app/api/auth/login/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/portal/src/app/api/auth/signup/route.ts
Normal file
31
apps/portal/src/app/api/auth/signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/portal/src/app/api/auth/validate-signup/route.ts
Normal file
31
apps/portal/src/app/api/auth/validate-signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,344 +11,649 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
|
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
const signupSchema = z
|
// Step 1: Customer Number Validation Schema
|
||||||
.object({
|
const step1Schema = z.object({
|
||||||
email: z.string().email("Please enter a valid email address"),
|
sfNumber: z.string().min(1, "Customer Number is required"),
|
||||||
confirmEmail: z.string().email("Please confirm with a valid email"),
|
});
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(8, "Password must be at least 8 characters")
|
|
||||||
.regex(
|
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
|
||||||
"Password must contain uppercase, lowercase, number, and special character"
|
|
||||||
),
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
firstName: z.string().min(1, "First name is required"),
|
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
|
||||||
company: z.string().optional(),
|
|
||||||
phone: z.string().optional(),
|
|
||||||
sfNumber: z.string().min(1, "Customer Number is required"),
|
|
||||||
addressLine1: z.string().optional(),
|
|
||||||
addressLine2: z.string().optional(),
|
|
||||||
city: z.string().optional(),
|
|
||||||
state: z.string().optional(),
|
|
||||||
postalCode: z.string().optional(),
|
|
||||||
country: z.string().optional(),
|
|
||||||
nationality: z.string().optional(),
|
|
||||||
dateOfBirth: z.string().optional(),
|
|
||||||
gender: z.enum(["male", "female", "other"]).optional(),
|
|
||||||
})
|
|
||||||
.refine(values => values.email === values.confirmEmail, {
|
|
||||||
message: "Emails do not match",
|
|
||||||
path: ["confirmEmail"],
|
|
||||||
})
|
|
||||||
.refine(values => values.password === values.confirmPassword, {
|
|
||||||
message: "Passwords do not match",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignupForm = z.infer<typeof signupSchema>;
|
// Step 2: Personal Information Schema
|
||||||
|
const step2Schema = z.object({
|
||||||
|
firstName: z.string().min(1, "First name is required"),
|
||||||
|
lastName: z.string().min(1, "Last name is required"),
|
||||||
|
email: z.string().email("Please enter a valid email address"),
|
||||||
|
confirmEmail: z.string().email("Please confirm with a valid email"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.regex(
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||||
|
"Password must contain uppercase, lowercase, number, and special character"
|
||||||
|
),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
}).refine(values => values.email === values.confirmEmail, {
|
||||||
|
message: "Emails do not match",
|
||||||
|
path: ["confirmEmail"],
|
||||||
|
}).refine(values => values.password === values.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3: Contact & Address Schema
|
||||||
|
const step3Schema = z.object({
|
||||||
|
company: z.string().optional(),
|
||||||
|
phone: z.string().min(1, "Phone number is required"),
|
||||||
|
addressLine1: z.string().min(1, "Address is required"),
|
||||||
|
addressLine2: z.string().optional(),
|
||||||
|
city: z.string().min(1, "City is required"),
|
||||||
|
state: z.string().min(1, "State/Prefecture is required"),
|
||||||
|
postalCode: z.string().min(1, "Postal code is required"),
|
||||||
|
country: z.string().min(2, "Please select a valid country").max(2, "Please select a valid country"),
|
||||||
|
nationality: z.string().optional(),
|
||||||
|
dateOfBirth: z.string().optional(),
|
||||||
|
gender: z.enum(["male", "female", "other"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Step1Form = z.infer<typeof step1Schema>;
|
||||||
|
type Step2Form = z.infer<typeof step2Schema>;
|
||||||
|
type Step3Form = z.infer<typeof step3Schema>;
|
||||||
|
|
||||||
|
interface SignupData {
|
||||||
|
sfNumber: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
company?: string;
|
||||||
|
phone: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
nationality?: string;
|
||||||
|
dateOfBirth?: string;
|
||||||
|
gender?: "male" | "female" | "other";
|
||||||
|
}
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { signup, isLoading } = useAuthStore();
|
const { signup, isLoading } = useAuthStore();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [validationStatus, setValidationStatus] = useState<{
|
||||||
|
sfNumberValid: boolean;
|
||||||
|
whAccountValid: boolean;
|
||||||
|
sfAccountId?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const {
|
// Step 1 Form
|
||||||
register,
|
const step1Form = useForm<Step1Form>({
|
||||||
handleSubmit,
|
resolver: zodResolver(step1Schema),
|
||||||
formState: { errors },
|
|
||||||
} = useForm<SignupForm>({
|
|
||||||
resolver: zodResolver(signupSchema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (data: SignupForm) => {
|
// Step 2 Form
|
||||||
|
const step2Form = useForm<Step2Form>({
|
||||||
|
resolver: zodResolver(step2Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 3 Form
|
||||||
|
const step3Form = useForm<Step3Form>({
|
||||||
|
resolver: zodResolver(step3Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Validate Customer Number
|
||||||
|
const onStep1Submit = async (data: Step1Form) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
await signup({
|
setValidationStatus(null);
|
||||||
email: data.email,
|
|
||||||
password: data.password,
|
// Call backend to validate SF number and WH Account field
|
||||||
firstName: data.firstName,
|
const response = await fetch("/api/auth/validate-signup", {
|
||||||
lastName: data.lastName,
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ sfNumber: data.sfNumber }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 409) {
|
||||||
|
// User already has account
|
||||||
|
setError("You already have an account. Please use the login page to access your existing account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(result.message || "Validation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationStatus({
|
||||||
|
sfNumberValid: true,
|
||||||
|
whAccountValid: true,
|
||||||
|
sfAccountId: result.sfAccountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setCurrentStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Validation failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Personal Information
|
||||||
|
const onStep2Submit = (data: Step2Form) => {
|
||||||
|
setCurrentStep(3);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Contact & Address
|
||||||
|
const onStep3Submit = async (data: Step3Form) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
console.log("Step 3 form data:", data);
|
||||||
|
console.log("Step 1 data:", step1Form.getValues());
|
||||||
|
console.log("Step 2 data:", step2Form.getValues());
|
||||||
|
|
||||||
|
const signupData: SignupData = {
|
||||||
|
sfNumber: step1Form.getValues("sfNumber"),
|
||||||
|
firstName: step2Form.getValues("firstName"),
|
||||||
|
lastName: step2Form.getValues("lastName"),
|
||||||
|
email: step2Form.getValues("email"),
|
||||||
|
password: step2Form.getValues("password"),
|
||||||
company: data.company,
|
company: data.company,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
sfNumber: data.sfNumber,
|
addressLine1: data.addressLine1,
|
||||||
address:
|
addressLine2: data.addressLine2,
|
||||||
data.addressLine1 || data.city || data.state || data.postalCode || data.country
|
city: data.city,
|
||||||
? {
|
state: data.state,
|
||||||
line1: data.addressLine1 || "",
|
postalCode: data.postalCode,
|
||||||
line2: data.addressLine2 || undefined,
|
country: data.country,
|
||||||
city: data.city || "",
|
nationality: data.nationality,
|
||||||
state: data.state || "",
|
dateOfBirth: data.dateOfBirth,
|
||||||
postalCode: data.postalCode || "",
|
gender: data.gender,
|
||||||
country: data.country || "",
|
};
|
||||||
}
|
|
||||||
: undefined,
|
await signup({
|
||||||
nationality: data.nationality || undefined,
|
email: signupData.email,
|
||||||
dateOfBirth: data.dateOfBirth || undefined,
|
password: signupData.password,
|
||||||
gender: data.gender || undefined,
|
firstName: signupData.firstName,
|
||||||
|
lastName: signupData.lastName,
|
||||||
|
company: signupData.company,
|
||||||
|
phone: signupData.phone,
|
||||||
|
sfNumber: signupData.sfNumber,
|
||||||
|
address: {
|
||||||
|
line1: signupData.addressLine1,
|
||||||
|
line2: signupData.addressLine2,
|
||||||
|
city: signupData.city,
|
||||||
|
state: signupData.state,
|
||||||
|
postalCode: signupData.postalCode,
|
||||||
|
country: signupData.country,
|
||||||
|
},
|
||||||
|
nationality: signupData.nationality,
|
||||||
|
dateOfBirth: signupData.dateOfBirth,
|
||||||
|
gender: signupData.gender,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Signup failed");
|
setError(err instanceof Error ? err.message : "Signup failed");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const goBack = () => {
|
||||||
<AuthLayout
|
if (currentStep > 1) {
|
||||||
title="Create your account"
|
setCurrentStep(currentStep - 1);
|
||||||
subtitle="Join Assist Solutions and manage your services"
|
setError(null);
|
||||||
>
|
}
|
||||||
<form
|
};
|
||||||
onSubmit={e => {
|
|
||||||
void handleSubmit(onSubmit)(e);
|
|
||||||
}}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
const renderStepIndicator = () => (
|
||||||
<div>
|
<div className="flex items-center justify-center mb-8">
|
||||||
<Label htmlFor="firstName">First name</Label>
|
<div className="flex items-center space-x-4">
|
||||||
<Input
|
{[1, 2, 3].map((step) => (
|
||||||
{...register("firstName")}
|
<div key={step} className="flex items-center">
|
||||||
id="firstName"
|
<div
|
||||||
type="text"
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
autoComplete="given-name"
|
currentStep >= step
|
||||||
className="mt-1"
|
? "bg-blue-600 text-white"
|
||||||
placeholder="John"
|
: "bg-gray-200 text-gray-600"
|
||||||
/>
|
}`}
|
||||||
{errors.firstName && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.firstName.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="lastName">Last name</Label>
|
|
||||||
<Input
|
|
||||||
{...register("lastName")}
|
|
||||||
id="lastName"
|
|
||||||
type="text"
|
|
||||||
autoComplete="family-name"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Doe"
|
|
||||||
/>
|
|
||||||
{errors.lastName && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.lastName.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="addressLine1">Home Address #1</Label>
|
|
||||||
<Input
|
|
||||||
{...register("addressLine1")}
|
|
||||||
id="addressLine1"
|
|
||||||
type="text"
|
|
||||||
autoComplete="address-line1"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Street, number"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="addressLine2">Home Address #2 (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...register("addressLine2")}
|
|
||||||
id="addressLine2"
|
|
||||||
type="text"
|
|
||||||
autoComplete="address-line2"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Apartment, suite, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="city">City</Label>
|
|
||||||
<Input {...register("city")} id="city" type="text" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="state">Prefecture</Label>
|
|
||||||
<Input {...register("state")} id="state" type="text" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="postalCode">Postal Code</Label>
|
|
||||||
<Input {...register("postalCode")} id="postalCode" type="text" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="country">Country</Label>
|
|
||||||
<Input {...register("country")} id="country" type="text" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="nationality">Nationality</Label>
|
|
||||||
<Input {...register("nationality")} id="nationality" type="text" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="phone">Phone (optional)</Label>
|
|
||||||
<Input
|
|
||||||
{...register("phone")}
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
autoComplete="tel"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="+81 90 1234 5678"
|
|
||||||
/>
|
|
||||||
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
|
||||||
<Input {...register("dateOfBirth")} id="dateOfBirth" type="date" className="mt-1" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="gender">Gender</Label>
|
|
||||||
<select
|
|
||||||
{...register("gender")}
|
|
||||||
id="gender"
|
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
|
||||||
>
|
>
|
||||||
<option value="">Select</option>
|
{step}
|
||||||
<option value="male">Male</option>
|
</div>
|
||||||
<option value="female">Female</option>
|
{step < 3 && (
|
||||||
<option value="other">Other</option>
|
<div
|
||||||
</select>
|
className={`w-12 h-0.5 mx-2 ${
|
||||||
</div>
|
currentStep > step ? "bg-blue-600" : "bg-gray-200"
|
||||||
<div>
|
}`}
|
||||||
<Label htmlFor="sfNumber">Customer Number</Label>
|
/>
|
||||||
<Input
|
|
||||||
{...register("sfNumber")}
|
|
||||||
id="sfNumber"
|
|
||||||
type="text"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Your SF Number"
|
|
||||||
/>
|
|
||||||
{errors.sfNumber && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.sfNumber.message}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStep1 = () => (
|
||||||
|
<form onSubmit={step1Form.handleSubmit(onStep1Submit)} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sfNumber">Customer Number (SF Number)</Label>
|
||||||
|
<Input
|
||||||
|
{...step1Form.register("sfNumber")}
|
||||||
|
id="sfNumber"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Enter your customer number"
|
||||||
|
/>
|
||||||
|
{step1Form.formState.errors.sfNumber && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step1Form.formState.errors.sfNumber.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationStatus && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
<span className="text-green-800 font-medium">Customer number validated successfully</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-green-700 text-sm mt-1">
|
||||||
|
Your customer number has been verified and is eligible for account creation.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
)}
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
<Input
|
{isLoading ? "Validating..." : "Continue"}
|
||||||
{...register("email")}
|
</Button>
|
||||||
id="email"
|
</form>
|
||||||
type="email"
|
);
|
||||||
autoComplete="email"
|
|
||||||
className="mt-1"
|
const renderStep2 = () => (
|
||||||
placeholder="john@example.com"
|
<form onSubmit={step2Form.handleSubmit(onStep2Submit)} className="space-y-6">
|
||||||
/>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>}
|
<div>
|
||||||
</div>
|
<Label htmlFor="firstName">First name</Label>
|
||||||
<div>
|
<Input
|
||||||
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
|
{...step2Form.register("firstName")}
|
||||||
<Input
|
id="firstName"
|
||||||
{...register("confirmEmail")}
|
type="text"
|
||||||
id="confirmEmail"
|
autoComplete="given-name"
|
||||||
type="email"
|
className="mt-1"
|
||||||
autoComplete="email"
|
placeholder="John"
|
||||||
className="mt-1"
|
/>
|
||||||
placeholder="john@example.com"
|
{step2Form.formState.errors.firstName && (
|
||||||
/>
|
<p className="mt-1 text-sm text-red-600">
|
||||||
{errors.confirmEmail && (
|
{step2Form.formState.errors.firstName.message}
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.confirmEmail.message}</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="lastName">Last name</Label>
|
||||||
|
<Input
|
||||||
|
{...step2Form.register("lastName")}
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="family-name"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
{step2Form.formState.errors.lastName && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step2Form.formState.errors.lastName.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email address</Label>
|
||||||
|
<Input
|
||||||
|
{...step2Form.register("email")}
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
{step2Form.formState.errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step2Form.formState.errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmEmail">Email address (confirm)</Label>
|
||||||
|
<Input
|
||||||
|
{...step2Form.register("confirmEmail")}
|
||||||
|
id="confirmEmail"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
/>
|
||||||
|
{step2Form.formState.errors.confirmEmail && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step2Form.formState.errors.confirmEmail.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
{...step2Form.register("password")}
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Create a secure password"
|
||||||
|
/>
|
||||||
|
{step2Form.formState.errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step2Form.formState.errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="confirmPassword">Password (confirm)</Label>
|
||||||
|
<Input
|
||||||
|
{...step2Form.register("confirmPassword")}
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
/>
|
||||||
|
{step2Form.formState.errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step2Form.formState.errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Continue
|
||||||
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStep3 = () => (
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
console.log("Step 3 form submit triggered");
|
||||||
|
console.log("Form errors:", step3Form.formState.errors);
|
||||||
|
step3Form.handleSubmit(onStep3Submit)(e);
|
||||||
|
}} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="company">Company (optional)</Label>
|
<Label htmlFor="company">Company (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
{...register("company")}
|
{...step3Form.register("company")}
|
||||||
id="company"
|
id="company"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
/>
|
/>
|
||||||
{errors.company && <p className="mt-1 text-sm text-red-600">{errors.company.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="phone">Phone (optional)</Label>
|
<Label htmlFor="phone">Phone number</Label>
|
||||||
<Input
|
<Input
|
||||||
{...register("phone")}
|
{...step3Form.register("phone")}
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
placeholder="+1 (555) 123-4567"
|
placeholder="+81 90 1234 5678"
|
||||||
/>
|
/>
|
||||||
{errors.phone && <p className="mt-1 text-sm text-red-600">{errors.phone.message}</p>}
|
{step3Form.formState.errors.phone && (
|
||||||
</div>
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.phone.message}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="password">Password</Label>
|
|
||||||
<Input
|
|
||||||
{...register("password")}
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="Create a secure password"
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
Must be at least 8 characters with uppercase, lowercase, number, and special character
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
<div>
|
</div>
|
||||||
<Label htmlFor="confirmPassword">Password (confirm)</Label>
|
</div>
|
||||||
<Input
|
|
||||||
{...register("confirmPassword")}
|
<div>
|
||||||
id="confirmPassword"
|
<Label htmlFor="addressLine1">Address Line 1</Label>
|
||||||
type="password"
|
<Input
|
||||||
autoComplete="new-password"
|
{...step3Form.register("addressLine1")}
|
||||||
className="mt-1"
|
id="addressLine1"
|
||||||
placeholder="Re-enter your password"
|
type="text"
|
||||||
/>
|
autoComplete="address-line1"
|
||||||
{errors.confirmPassword && (
|
className="mt-1"
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
placeholder="Street, number"
|
||||||
)}
|
/>
|
||||||
</div>
|
{step3Form.formState.errors.addressLine1 && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.addressLine1.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="addressLine2">Address Line 2 (optional)</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("addressLine2")}
|
||||||
|
id="addressLine2"
|
||||||
|
type="text"
|
||||||
|
autoComplete="address-line2"
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="Apartment, suite, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="city">City</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("city")}
|
||||||
|
id="city"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{step3Form.formState.errors.city && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.city.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
<div>
|
||||||
|
<Label htmlFor="state">State/Prefecture</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("state")}
|
||||||
|
id="state"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{step3Form.formState.errors.state && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.state.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="postalCode">Postal Code</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("postalCode")}
|
||||||
|
id="postalCode"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
{step3Form.formState.errors.postalCode && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.postalCode.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="country">Country</Label>
|
||||||
|
<select
|
||||||
|
{...step3Form.register("country")}
|
||||||
|
id="country"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
||||||
|
>
|
||||||
|
<option value="">Select Country</option>
|
||||||
|
<option value="JP">Japan</option>
|
||||||
|
<option value="US">United States</option>
|
||||||
|
<option value="GB">United Kingdom</option>
|
||||||
|
<option value="CA">Canada</option>
|
||||||
|
<option value="AU">Australia</option>
|
||||||
|
<option value="DE">Germany</option>
|
||||||
|
<option value="FR">France</option>
|
||||||
|
<option value="IT">Italy</option>
|
||||||
|
<option value="ES">Spain</option>
|
||||||
|
<option value="NL">Netherlands</option>
|
||||||
|
<option value="SE">Sweden</option>
|
||||||
|
<option value="NO">Norway</option>
|
||||||
|
<option value="DK">Denmark</option>
|
||||||
|
<option value="FI">Finland</option>
|
||||||
|
<option value="CH">Switzerland</option>
|
||||||
|
<option value="AT">Austria</option>
|
||||||
|
<option value="BE">Belgium</option>
|
||||||
|
<option value="IE">Ireland</option>
|
||||||
|
<option value="PT">Portugal</option>
|
||||||
|
<option value="GR">Greece</option>
|
||||||
|
<option value="PL">Poland</option>
|
||||||
|
<option value="CZ">Czech Republic</option>
|
||||||
|
<option value="HU">Hungary</option>
|
||||||
|
<option value="SK">Slovakia</option>
|
||||||
|
<option value="SI">Slovenia</option>
|
||||||
|
<option value="HR">Croatia</option>
|
||||||
|
<option value="BG">Bulgaria</option>
|
||||||
|
<option value="RO">Romania</option>
|
||||||
|
<option value="LT">Lithuania</option>
|
||||||
|
<option value="LV">Latvia</option>
|
||||||
|
<option value="EE">Estonia</option>
|
||||||
|
<option value="MT">Malta</option>
|
||||||
|
<option value="CY">Cyprus</option>
|
||||||
|
<option value="LU">Luxembourg</option>
|
||||||
|
</select>
|
||||||
|
{step3Form.formState.errors.country && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">
|
||||||
|
{step3Form.formState.errors.country.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="nationality">Nationality (optional)</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("nationality")}
|
||||||
|
id="nationality"
|
||||||
|
type="text"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateOfBirth">Date of Birth (optional)</Label>
|
||||||
|
<Input
|
||||||
|
{...step3Form.register("dateOfBirth")}
|
||||||
|
id="dateOfBirth"
|
||||||
|
type="date"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="gender">Gender (optional)</Label>
|
||||||
|
<select
|
||||||
|
{...step3Form.register("gender")}
|
||||||
|
id="gender"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md p-2"
|
||||||
|
>
|
||||||
|
<option value="">Select</option>
|
||||||
|
<option value="male">Male</option>
|
||||||
|
<option value="female">Female</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Button type="button" variant="outline" onClick={goBack} className="flex-1">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => console.log("Create account button clicked")}
|
||||||
|
>
|
||||||
{isLoading ? "Creating account..." : "Create account"}
|
{isLoading ? "Creating account..." : "Create account"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
return (
|
||||||
<p className="text-sm text-gray-600">
|
<AuthLayout
|
||||||
Already have an account?{" "}
|
title="Create your account"
|
||||||
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
subtitle="Join Assist Solutions and manage your services"
|
||||||
Sign in here
|
>
|
||||||
</Link>
|
{renderStepIndicator()}
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-600">
|
{error && (
|
||||||
Already a customer?{" "}
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">
|
<div className="flex items-center space-x-2">
|
||||||
Transfer your existing account
|
<XCircle className="w-5 h-5" />
|
||||||
</Link>
|
<span>{error}</span>
|
||||||
</p>
|
</div>
|
||||||
|
{error.includes("already have an account") && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500 underline">
|
||||||
|
Go to login page
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
|
||||||
|
{currentStep === 1 && renderStep1()}
|
||||||
|
{currentStep === 2 && renderStep2()}
|
||||||
|
{currentStep === 3 && renderStep3()}
|
||||||
|
|
||||||
|
<div className="text-center space-y-2 mt-8">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/auth/login" className="text-blue-600 hover:text-blue-500">
|
||||||
|
Sign in here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Already a customer?{" "}
|
||||||
|
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">
|
||||||
|
Transfer your existing account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,74 +131,46 @@ export default function InternetPlansPage() {
|
|||||||
<span className="font-medium">Available for: {eligibility}</span>
|
<span className="font-medium">Available for: {eligibility}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto">
|
<p className="text-sm text-gray-500 mt-2 max-w-2xl mx-auto">
|
||||||
Plans shown are tailored to your dwelling type and local infrastructure
|
Plans shown are tailored to your house type and local infrastructure
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Comparison */}
|
|
||||||
{plans.length > 1 && (
|
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 mb-8 border border-blue-200">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Plan Comparison</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
||||||
{/* Silver Plan */}
|
|
||||||
{plans.some(p => p.tier === "Silver") && (
|
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h4 className="font-medium text-gray-900">Silver</h4>
|
|
||||||
<span className="text-xs text-gray-500">Basic</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-gray-600">
|
|
||||||
<div>✓ NTT modem</div>
|
|
||||||
<div>⚠ Router required</div>
|
|
||||||
<div>• Customer setup</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Gold Plan */}
|
|
||||||
{plans.some(p => p.tier === "Gold") && (
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 border border-green-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h4 className="font-medium text-green-900">Gold</h4>
|
|
||||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full">
|
|
||||||
Recommended
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-green-700">
|
|
||||||
<div>✓ Complete router solution</div>
|
|
||||||
<div>✓ Professional setup</div>
|
|
||||||
<div>✓ Optimal performance</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Platinum Plan */}
|
|
||||||
{plans.some(p => p.tier === "Platinum") && (
|
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h4 className="font-medium text-gray-900">Platinum</h4>
|
|
||||||
<span className="text-xs text-purple-600">Residences >50㎡</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 text-gray-600">
|
|
||||||
<div>✓ INSIGHT management</div>
|
|
||||||
<div>✓ Multiple routers</div>
|
|
||||||
<div>💰 ¥500/month cloud management fee per router</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Plans Grid */}
|
{/* Plans Grid */}
|
||||||
{plans.length > 0 ? (
|
{plans.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<>
|
||||||
{plans.map(plan => (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<InternetPlanCard key={plan.id} plan={plan} />
|
{plans.map(plan => (
|
||||||
))}
|
<InternetPlanCard key={plan.id} plan={plan} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Important Notes */}
|
||||||
|
<div className="mt-12 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200">
|
||||||
|
<h4 className="font-medium text-blue-900 mb-4 text-lg">Important Notes:</h4>
|
||||||
|
<ul className="text-sm text-blue-800 space-y-2">
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-blue-600 mr-2">•</span>
|
||||||
|
Theoretical internet speed is the same for all three packages
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-blue-600 mr-2">•</span>
|
||||||
|
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-blue-600 mr-2">•</span>
|
||||||
|
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + ¥1,000-3,000 one-time)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-blue-600 mr-2">•</span>
|
||||||
|
In-home technical assistance available (¥15,000 onsite visiting fee)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
@ -220,11 +192,21 @@ export default function InternetPlansPage() {
|
|||||||
function InternetPlanCard({ plan }: { plan: InternetPlan }) {
|
function InternetPlanCard({ plan }: { plan: InternetPlan }) {
|
||||||
const isGold = plan.tier === "Gold";
|
const isGold = plan.tier === "Gold";
|
||||||
const isPlatinum = plan.tier === "Platinum";
|
const isPlatinum = plan.tier === "Platinum";
|
||||||
|
const isSilver = plan.tier === "Silver";
|
||||||
|
|
||||||
const cardVariant = isGold ? "success" : isPlatinum ? "highlighted" : "default";
|
// Use default variant for all cards to avoid green background on gold
|
||||||
|
const cardVariant = "default";
|
||||||
|
|
||||||
|
// Custom border colors for each tier
|
||||||
|
const getBorderClass = () => {
|
||||||
|
if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl";
|
||||||
|
if (isPlatinum) return "border-2 border-indigo-400 shadow-lg hover:shadow-xl";
|
||||||
|
if (isSilver) return "border-2 border-gray-300 shadow-lg hover:shadow-xl";
|
||||||
|
return "border border-gray-200 shadow-lg hover:shadow-xl";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard variant={cardVariant} className="overflow-hidden flex flex-col h-full">
|
<AnimatedCard variant={cardVariant} className={`overflow-hidden flex flex-col h-full ${getBorderClass()}`}>
|
||||||
<div className="p-6 flex flex-col flex-grow">
|
<div className="p-6 flex flex-col flex-grow">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@ -260,30 +242,42 @@ function InternetPlanCard({ plan }: { plan: InternetPlan }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plan Details */}
|
{/* Plan Details */}
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{plan.name}</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
<p className="text-gray-600 text-sm mb-4">{plan.tierDescription}</p>
|
{plan.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm mb-4">
|
||||||
|
{plan.tierDescription || plan.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Your Plan Includes */}
|
{/* Your Plan Includes */}
|
||||||
<div className="mb-6 flex-grow">
|
<div className="mb-6 flex-grow">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
|
<h4 className="font-medium text-gray-900 mb-3">Your Plan Includes:</h4>
|
||||||
<ul className="space-y-2 text-sm text-gray-700">
|
<ul className="space-y-2 text-sm text-gray-700">
|
||||||
<li className="flex items-start">
|
{plan.features && plan.features.length > 0 ? (
|
||||||
<span className="text-green-600 mr-2">✓</span>1 NTT Optical Fiber (Flet's Hikari
|
plan.features.map((feature, index) => (
|
||||||
Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
<li key={index} className="flex items-start">
|
||||||
{plan.offeringType?.includes("10G")
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
? "10Gbps"
|
{feature}
|
||||||
: plan.offeringType?.includes("100M")
|
</li>
|
||||||
? "100Mbps"
|
))
|
||||||
: "1Gbps"}
|
) : (
|
||||||
) Installation + Monthly
|
<>
|
||||||
</li>
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>1 NTT Optical Fiber (Flet's Hikari
|
||||||
{plan.features.map((feature, index) => (
|
Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "}
|
||||||
<li key={index} className="flex items-start">
|
{plan.offeringType?.includes("10G")
|
||||||
<span className="text-green-600 mr-2">✓</span>
|
? "10Gbps"
|
||||||
{feature}
|
: plan.offeringType?.includes("100M")
|
||||||
</li>
|
? "100Mbps"
|
||||||
))}
|
: "1Gbps"}
|
||||||
|
) Installation + Monthly
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<span className="text-green-600 mr-2">✓</span>
|
||||||
|
Monthly: ¥{plan.monthlyPrice?.toLocaleString()} | One-time: ¥{plan.setupFee?.toLocaleString() || '22,800'}
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -99,7 +99,7 @@ export default function CatalogPage() {
|
|||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
|
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
|
||||||
title="Location-Based Plans"
|
title="Location-Based Plans"
|
||||||
description="Internet plans tailored to your dwelling type and available infrastructure"
|
description="Internet plans tailored to your house type and available infrastructure"
|
||||||
/>
|
/>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={<SignalIcon className="h-10 w-10 text-green-600" />}
|
icon={<SignalIcon className="h-10 w-10 text-green-600" />}
|
||||||
|
|||||||
@ -45,7 +45,7 @@ function PlanTypeSection({
|
|||||||
const familyPlans = plans.filter(p => p.hasFamilyDiscount);
|
const familyPlans = plans.filter(p => p.hasFamilyDiscount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-12">
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
{icon}
|
{icon}
|
||||||
<div>
|
<div>
|
||||||
@ -55,7 +55,7 @@ function PlanTypeSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Regular Plans */}
|
{/* Regular Plans */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-6 justify-items-center">
|
||||||
{regularPlans.map(plan => (
|
{regularPlans.map(plan => (
|
||||||
<PlanCard key={plan.id} plan={plan} isFamily={false} />
|
<PlanCard key={plan.id} plan={plan} isFamily={false} />
|
||||||
))}
|
))}
|
||||||
@ -71,7 +71,7 @@ function PlanTypeSection({
|
|||||||
You qualify!
|
You qualify!
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 justify-items-center">
|
||||||
{familyPlans.map(plan => (
|
{familyPlans.map(plan => (
|
||||||
<PlanCard key={plan.id} plan={plan} isFamily={true} />
|
<PlanCard key={plan.id} plan={plan} isFamily={true} />
|
||||||
))}
|
))}
|
||||||
@ -84,7 +84,7 @@ function PlanTypeSection({
|
|||||||
|
|
||||||
function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
|
function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) {
|
||||||
return (
|
return (
|
||||||
<AnimatedCard variant={isFamily ? "success" : "default"} className="p-4">
|
<AnimatedCard variant={isFamily ? "success" : "default"} className="p-6 w-full max-w-sm">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@ -131,6 +131,7 @@ export default function SimPlansPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasExistingSim, setHasExistingSim] = useState(false);
|
const [hasExistingSim, setHasExistingSim] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'data-voice' | 'data-only' | 'voice-only'>('data-voice');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@ -209,9 +210,9 @@ export default function SimPlansPage() {
|
|||||||
description="Choose your mobile plan with flexible options"
|
description="Choose your mobile plan with flexible options"
|
||||||
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
icon={<DevicePhoneMobileIcon className="h-6 w-6" />}
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex justify-center">
|
||||||
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
|
<AnimatedButton href="/catalog" variant="outline" size="sm" className="group">
|
||||||
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-300" />
|
||||||
Back to Services
|
Back to Services
|
||||||
@ -258,35 +259,97 @@ export default function SimPlansPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Data + Voice Plans (Most Popular) */}
|
{/* Tab Navigation */}
|
||||||
<PlanTypeSection
|
<div className="mb-8 flex justify-center">
|
||||||
title="Data + Voice Plans"
|
<div className="border-b border-gray-200">
|
||||||
description="Internet, calling, and SMS included"
|
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||||
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
|
<button
|
||||||
plans={plansByType.DataSmsVoice}
|
onClick={() => setActiveTab('data-voice')}
|
||||||
showFamilyDiscount={hasExistingSim}
|
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
/>
|
activeTab === 'data-voice'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PhoneIcon className="h-5 w-5" />
|
||||||
|
Data + Voice
|
||||||
|
{plansByType.DataSmsVoice.length > 0 && (
|
||||||
|
<span className="bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full">
|
||||||
|
{plansByType.DataSmsVoice.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('data-only')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
|
activeTab === 'data-only'
|
||||||
|
? 'border-purple-500 text-purple-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GlobeAltIcon className="h-5 w-5" />
|
||||||
|
Data Only
|
||||||
|
{plansByType.DataOnly.length > 0 && (
|
||||||
|
<span className="bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full">
|
||||||
|
{plansByType.DataOnly.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('voice-only')}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
|
activeTab === 'voice-only'
|
||||||
|
? 'border-orange-500 text-orange-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<PhoneIcon className="h-5 w-5" />
|
||||||
|
Voice Only
|
||||||
|
{plansByType.VoiceOnly.length > 0 && (
|
||||||
|
<span className="bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full">
|
||||||
|
{plansByType.VoiceOnly.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Data Only Plans */}
|
{/* Tab Content */}
|
||||||
<PlanTypeSection
|
<div className="min-h-[400px]">
|
||||||
title="Data Only Plans"
|
{activeTab === 'data-voice' && (
|
||||||
description="Internet access for tablets, laptops, and IoT devices"
|
<PlanTypeSection
|
||||||
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
|
title="Data + Voice Plans"
|
||||||
plans={plansByType.DataOnly}
|
description="Internet, calling, and SMS included"
|
||||||
showFamilyDiscount={hasExistingSim}
|
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
|
||||||
/>
|
plans={plansByType.DataSmsVoice}
|
||||||
|
showFamilyDiscount={hasExistingSim}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Voice Only Plans */}
|
{activeTab === 'data-only' && (
|
||||||
<PlanTypeSection
|
<PlanTypeSection
|
||||||
title="Voice Only Plans"
|
title="Data Only Plans"
|
||||||
description="Traditional calling and SMS without internet"
|
description="Internet access for tablets, laptops, and IoT devices"
|
||||||
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
|
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
|
||||||
plans={plansByType.VoiceOnly}
|
plans={plansByType.DataOnly}
|
||||||
showFamilyDiscount={hasExistingSim}
|
showFamilyDiscount={hasExistingSim}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'voice-only' && (
|
||||||
|
<PlanTypeSection
|
||||||
|
title="Voice Only Plans"
|
||||||
|
description="Traditional calling and SMS without internet"
|
||||||
|
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
|
||||||
|
plans={plansByType.VoiceOnly}
|
||||||
|
showFamilyDiscount={hasExistingSim}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<div className="mt-16 bg-gray-50 rounded-2xl p-8">
|
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">
|
||||||
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
|
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
|
||||||
All SIM Plans Include
|
All SIM Plans Include
|
||||||
</h3>
|
</h3>
|
||||||
@ -323,7 +386,7 @@ export default function SimPlansPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Section */}
|
{/* Info Section */}
|
||||||
<div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3">
|
<div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3 max-w-4xl mx-auto">
|
||||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<div className="font-medium text-blue-900 mb-1">Getting Started</div>
|
<div className="font-medium text-blue-900 mb-1">Getting Started</div>
|
||||||
|
|||||||
@ -104,96 +104,112 @@ export default function VpnPlansPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">VPN Router Rental</h1>
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">SonixNet VPN Rental Router Service</h1>
|
||||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
Secure VPN router rental service for business and personal use with enterprise-grade
|
Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.
|
||||||
security.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
{/* Available Plans Section */}
|
||||||
{vpnPlans.map(plan => {
|
{vpnPlans.length > 0 ? (
|
||||||
const activationFee = getActivationFeeForRegion();
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2 text-center">Available Plans</h2>
|
||||||
|
<p className="text-gray-600 text-center mb-6">(One region per router)</p>
|
||||||
|
|
||||||
|
<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 (
|
return (
|
||||||
<AnimatedCard key={plan.id} className="p-6">
|
<AnimatedCard key={plan.id} className="p-6 border-2 border-blue-200 hover:border-blue-300 transition-colors">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="text-center mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-xl font-bold text-gray-900">{plan.name}</h3>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 text-center">
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline justify-center gap-1">
|
||||||
<CurrencyYenIcon className="h-4 w-4 text-gray-600" />
|
<CurrencyYenIcon className="h-5 w-5 text-gray-600" />
|
||||||
<span className="text-2xl font-bold text-gray-900">
|
<span className="text-3xl font-bold text-gray-900">
|
||||||
{plan.monthlyPrice?.toLocaleString()}
|
{plan.monthlyPrice?.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600">/month</span>
|
<span className="text-gray-600">/month</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<p className="text-sm text-gray-600 mb-3">{plan.description}</p>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-gray-600">Enterprise-grade security</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-gray-600">24/7 monitoring</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-2 text-sm">
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<span className="text-gray-600">Remote management</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activationFee && (
|
{plan.features && plan.features.length > 0 && (
|
||||||
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
<div className="mb-6">
|
||||||
<div className="text-sm font-medium text-orange-800">Setup Fee</div>
|
<h4 className="font-medium text-gray-900 mb-3">Features:</h4>
|
||||||
<div className="text-lg font-bold text-orange-600">
|
<ul className="text-sm text-gray-700 space-y-1">
|
||||||
¥{activationFee.price.toLocaleString()} one-time
|
{plan.features.map((feature, index) => (
|
||||||
</div>
|
<li key={index} className="flex items-start">
|
||||||
</div>
|
<CheckIcon className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
)}
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AnimatedButton className="w-full" variant="secondary">
|
<AnimatedButton
|
||||||
Request Quote
|
href={`/catalog/vpn/configure?plan=${plan.sku}`}
|
||||||
</AnimatedButton>
|
className="w-full"
|
||||||
</AnimatedCard>
|
>
|
||||||
);
|
Configure Plan
|
||||||
})}
|
</AnimatedButton>
|
||||||
</div>
|
</AnimatedCard>
|
||||||
|
);
|
||||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-xl p-8 text-center">
|
})}
|
||||||
<GlobeAltIcon className="h-12 w-12 text-green-600 mx-auto mb-4" />
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Enterprise VPN Solutions</h3>
|
|
||||||
<p className="text-gray-600 mb-6 max-w-2xl mx-auto">
|
|
||||||
Secure your business communications with our managed VPN router rental service. Perfect
|
|
||||||
for companies requiring reliable and secure remote access.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-green-600">Secure</div>
|
|
||||||
<div className="text-gray-600">Bank-level encryption</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-green-600">Managed</div>
|
|
||||||
<div className="text-gray-600">We handle the setup</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="font-medium text-green-600">Reliable</div>
|
|
||||||
<div className="text-gray-600">99.9% uptime guarantee</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activationFees.length > 0 && (
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg max-w-4xl mx-auto">
|
||||||
|
<p className="text-sm text-blue-800 text-center">
|
||||||
|
A one-time activation fee of 3000 JPY is incurred seprarately for each rental unit. Tax (10%) not included.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ShieldCheckIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No VPN Plans Available</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
We couldn't find any VPN plans available at this time.
|
||||||
|
</p>
|
||||||
|
<AnimatedButton href="/catalog" className="flex items-center">
|
||||||
|
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||||
|
Back to Services
|
||||||
|
</AnimatedButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service Description Section */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-8 mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">How It Works</h2>
|
||||||
|
<div className="space-y-4 text-gray-700">
|
||||||
|
<p>
|
||||||
|
SonixNet VPN is the easiest way to access video streaming services from overseas on your network media players such as an Apple TV, Roku, or Amazon Fire.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). All you will need to do is to plug the VPN router into your existing internet connection.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Then you can connect your network media players to the VPN Wi-Fi network, to connect to the VPN server.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
For daily Internet usage that does not require a VPN, we recommend connecting to your regular home Wi-Fi.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Disclaimer Section */}
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-6 mb-8">
|
||||||
|
<h3 className="font-bold text-yellow-900 mb-3">Important Disclaimer</h3>
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
*1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will establish a network connection that virtually locates you in the designated server location, then you will sign up for the streaming services of your choice. Not all services/websites can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the unblocking of any websites or the quality of the streaming/browsing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,9 +3,10 @@
|
|||||||
import { useState, useEffect, useMemo, Suspense } from "react";
|
import { useState, useEffect, useMemo, Suspense } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
|
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
|
||||||
|
import { usePaymentMethods } from "@/hooks/useInvoices";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InternetPlan,
|
InternetPlan,
|
||||||
@ -44,6 +45,9 @@ function CheckoutContent() {
|
|||||||
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
|
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch payment methods to check if user has payment method on file
|
||||||
|
const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods();
|
||||||
|
|
||||||
const orderType = (() => {
|
const orderType = (() => {
|
||||||
const type = params.get("type") || "internet";
|
const type = params.get("type") || "internet";
|
||||||
// Map to backend expected values
|
// Map to backend expected values
|
||||||
@ -329,26 +333,110 @@ function CheckoutContent() {
|
|||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
{paymentMethodsLoading ? (
|
||||||
<div className="flex items-start gap-3">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="flex items-center gap-3">
|
||||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
||||||
<path
|
<span className="text-gray-600 text-sm">Checking payment methods...</span>
|
||||||
fillRule="evenodd"
|
|
||||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
|
|
||||||
<p className="text-green-700 text-sm mt-1">
|
|
||||||
After order approval, payment will be automatically processed using your existing
|
|
||||||
payment method on file. No additional payment steps required.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : paymentMethodsError ? (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
|
||||||
|
<p className="text-amber-700 text-sm mt-1">
|
||||||
|
We couldn'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>
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
@ -373,7 +461,14 @@ function CheckoutContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleSubmitOrder()}
|
onClick={() => void handleSubmitOrder()}
|
||||||
disabled={submitting || checkoutState.orderItems.length === 0 || !addressConfirmed}
|
disabled={
|
||||||
|
submitting ||
|
||||||
|
checkoutState.orderItems.length === 0 ||
|
||||||
|
!addressConfirmed ||
|
||||||
|
paymentMethodsLoading ||
|
||||||
|
!paymentMethods ||
|
||||||
|
paymentMethods.paymentMethods.length === 0
|
||||||
|
}
|
||||||
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
@ -402,6 +497,10 @@ function CheckoutContent() {
|
|||||||
</span>
|
</span>
|
||||||
) : !addressConfirmed ? (
|
) : !addressConfirmed ? (
|
||||||
"📍 Complete Address to Continue"
|
"📍 Complete Address to Continue"
|
||||||
|
) : paymentMethodsLoading ? (
|
||||||
|
"⏳ Verifying Payment Method..."
|
||||||
|
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
|
||||||
|
"💳 Add Payment Method to Continue"
|
||||||
) : (
|
) : (
|
||||||
"📋 Submit Order for Review"
|
"📋 Submit Order for Review"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -28,12 +28,7 @@ import {
|
|||||||
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
|
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import { StatCard, QuickAction, DashboardActivityItem } from "@/features/dashboard/components";
|
||||||
StatCard,
|
|
||||||
QuickAction,
|
|
||||||
DashboardActivityItem,
|
|
||||||
AccountStatusCard,
|
|
||||||
} from "@/features/dashboard/components";
|
|
||||||
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
@ -117,18 +112,18 @@ export default function DashboardPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
Welcome back, {user?.firstName || user?.email?.split("@")[0] || "User"}! 👋
|
Welcome back, {user?.firstName || user?.email?.split("@")[0] || "User"}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600">Here's your account overview for today</p>
|
<p className="text-lg text-gray-600">Here's your account overview for today</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<div className="hidden md:flex items-center space-x-4">
|
||||||
<button className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg.white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
<button className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-lg shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||||
<BellIcon className="h-4 w-4 mr-2" />
|
<BellIcon className="h-4 w-4 mr-2" />
|
||||||
Notifications
|
Notifications
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/catalog"
|
href="/catalog"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors"
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
Order Services
|
Order Services
|
||||||
@ -157,7 +152,7 @@ export default function DashboardPage() {
|
|||||||
title="Recent Orders"
|
title="Recent Orders"
|
||||||
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
|
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
|
||||||
icon={ClipboardDocumentListIconSolid}
|
icon={ClipboardDocumentListIconSolid}
|
||||||
gradient="from-indigo-500 to-purple-500"
|
gradient="from-gray-500 to-gray-600"
|
||||||
href="/orders"
|
href="/orders"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@ -166,8 +161,8 @@ export default function DashboardPage() {
|
|||||||
icon={CreditCardIconSolid}
|
icon={CreditCardIconSolid}
|
||||||
gradient={
|
gradient={
|
||||||
(summary?.stats?.unpaidInvoices ?? 0) > 0
|
(summary?.stats?.unpaidInvoices ?? 0) > 0
|
||||||
? "from-red-500 to-pink-500"
|
? "from-amber-500 to-orange-500"
|
||||||
: "from-green-500 to-emerald-500"
|
: "from-gray-500 to-gray-600"
|
||||||
}
|
}
|
||||||
href="/billing/invoices"
|
href="/billing/invoices"
|
||||||
/>
|
/>
|
||||||
@ -177,8 +172,8 @@ export default function DashboardPage() {
|
|||||||
icon={ChatBubbleLeftRightIconSolid}
|
icon={ChatBubbleLeftRightIconSolid}
|
||||||
gradient={
|
gradient={
|
||||||
(summary?.stats?.openCases ?? 0) > 0
|
(summary?.stats?.openCases ?? 0) > 0
|
||||||
? "from-amber-500 to-orange-500"
|
? "from-blue-500 to-cyan-500"
|
||||||
: "from-green-500 to-emerald-500"
|
: "from-gray-500 to-gray-600"
|
||||||
}
|
}
|
||||||
href="/support/cases"
|
href="/support/cases"
|
||||||
/>
|
/>
|
||||||
@ -190,14 +185,14 @@ export default function DashboardPage() {
|
|||||||
{/* Next Invoice Due - Enhanced */}
|
{/* Next Invoice Due - Enhanced */}
|
||||||
{summary?.nextInvoice && (
|
{summary?.nextInvoice && (
|
||||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
<div className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden">
|
||||||
<div className="bg-gradient-to-r from-orange-500 to-red-500 px-6 py-4">
|
<div className="bg-gradient-to-r from-amber-500 to-orange-500 px-6 py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<CalendarDaysIcon className="h-8 w-8 text-white" />
|
<CalendarDaysIcon className="h-8 w-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<h3 className="text-lg font-semibold text-white">Upcoming Payment</h3>
|
<h3 className="text-lg font-semibold text-white">Upcoming Payment</h3>
|
||||||
<p className="text-orange-100 text-sm">
|
<p className="text-amber-100 text-sm">
|
||||||
Don't forget your next payment
|
Don't forget your next payment
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -225,7 +220,7 @@ export default function DashboardPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handlePayNow(summary.nextInvoice!.id)}
|
onClick={() => handlePayNow(summary.nextInvoice!.id)}
|
||||||
disabled={paymentLoading}
|
disabled={paymentLoading}
|
||||||
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-500 hover:from-orange-600 hover:to-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{paymentLoading ? (
|
{paymentLoading ? (
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
@ -266,7 +261,15 @@ export default function DashboardPage() {
|
|||||||
<div className="px-6 py-4 border-b border-gray-100">
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
|
<h3 className="text-lg font-semibold text-gray-900">Recent Activity</h3>
|
||||||
<span className="text-sm text-gray-500">Last 30 days</span>
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm text-gray-500">Last 30 days</span>
|
||||||
|
<Link
|
||||||
|
href="/activity"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
View All
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@ -314,8 +317,8 @@ export default function DashboardPage() {
|
|||||||
title="Track Orders"
|
title="Track Orders"
|
||||||
description="View and track your orders"
|
description="View and track your orders"
|
||||||
icon={ClipboardDocumentListIcon}
|
icon={ClipboardDocumentListIcon}
|
||||||
iconColor="text-indigo-600"
|
iconColor="text-blue-600"
|
||||||
bgColor="bg-indigo-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
href="/subscriptions"
|
href="/subscriptions"
|
||||||
@ -330,38 +333,35 @@ export default function DashboardPage() {
|
|||||||
title="Order Services"
|
title="Order Services"
|
||||||
description="Browse catalog and add services"
|
description="Browse catalog and add services"
|
||||||
icon={PlusIcon}
|
icon={PlusIcon}
|
||||||
iconColor="text-purple-600"
|
iconColor="text-blue-600"
|
||||||
bgColor="bg-purple-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
href="/billing/invoices"
|
href="/billing/invoices"
|
||||||
title="View Invoices"
|
title="View Invoices"
|
||||||
description="Check your billing history"
|
description="Check your billing history"
|
||||||
icon={DocumentTextIcon}
|
icon={DocumentTextIcon}
|
||||||
iconColor="text-green-600"
|
iconColor="text-blue-600"
|
||||||
bgColor="bg-green-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
href="/support/new"
|
href="/support/new"
|
||||||
title="Get Support"
|
title="Get Support"
|
||||||
description="Create a new support ticket"
|
description="Create a new support ticket"
|
||||||
icon={ChatBubbleLeftRightIcon}
|
icon={ChatBubbleLeftRightIcon}
|
||||||
iconColor="text-purple-600"
|
iconColor="text-blue-600"
|
||||||
bgColor="bg-purple-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
<QuickAction
|
<QuickAction
|
||||||
href="/billing/payments"
|
href="/billing/payments"
|
||||||
title="Payment Methods"
|
title="Payment Methods"
|
||||||
description="Manage your payment options"
|
description="Manage your payment options"
|
||||||
icon={CreditCardIcon}
|
icon={CreditCardIcon}
|
||||||
iconColor="text-orange-600"
|
iconColor="text-blue-600"
|
||||||
bgColor="bg-orange-50"
|
bgColor="bg-blue-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Account Status */}
|
|
||||||
<AccountStatusCard />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,16 +1,26 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Logo } from "@/components/ui/logo";
|
||||||
|
import {
|
||||||
|
ArrowPathIcon,
|
||||||
|
UserIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
CreditCardIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-blue-100 to-blue-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white shadow-sm border-b">
|
<header className="bg-white/90 backdrop-blur-sm shadow-sm border-b border-blue-100">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
<Logo size={40} />
|
||||||
<span className="text-white font-bold text-lg">AS</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
|
<h1 className="text-xl font-semibold text-gray-900">Assist Solutions</h1>
|
||||||
<p className="text-xs text-gray-500">Customer Portal</p>
|
<p className="text-xs text-gray-500">Customer Portal</p>
|
||||||
@ -32,83 +42,58 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-gradient-to-br from-blue-50 to-indigo-100 py-16">
|
<section className="relative py-20 overflow-hidden">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
{/* Abstract background elements */}
|
||||||
<div className="text-center">
|
<div className="absolute inset-0">
|
||||||
<div className="inline-flex items-center bg-blue-100 text-blue-800 px-4 py-2 rounded-full text-sm font-medium mb-4">
|
<div className="absolute top-20 left-10 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
|
||||||
✨ New Portal Available
|
<div className="absolute top-40 right-10 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse" style={{animationDelay: '2s'}}></div>
|
||||||
</div>
|
<div className="absolute -bottom-8 left-20 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse" style={{animationDelay: '4s'}}></div>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
</div>
|
||||||
New Assist Solutions Customer Portal
|
|
||||||
</h1>
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<p className="text-xl text-gray-600 mb-4 max-w-3xl mx-auto">
|
<div className="text-center">
|
||||||
Experience our completely redesigned customer portal with enhanced features, better
|
<h1 className="text-5xl md:text-6xl font-bold text-gray-900 mb-6">
|
||||||
performance, and improved user experience.
|
New Assist Solutions Customer Portal
|
||||||
</p>
|
</h1>
|
||||||
<p className="text-lg text-gray-500 mb-8">
|
|
||||||
Modern Interface • Enhanced Security • 24/7 Availability • English Support
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<a
|
<div className="flex justify-center">
|
||||||
href="#portal-access"
|
<a
|
||||||
className="bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-colors font-semibold text-lg"
|
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>
|
Get Started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Customer Portal Access Section */}
|
{/* Customer Portal Access Section */}
|
||||||
<section id="portal-access" className="py-20 bg-white">
|
<section id="portal-access" className="py-20">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">Access Your Portal</h2>
|
||||||
<p className="text-lg text-gray-600">Choose the option that applies to you</p>
|
<p className="text-lg text-gray-600">Choose the option that applies to you</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10 items-stretch">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch">
|
||||||
{/* Existing Customers - Migration */}
|
{/* Existing Customers - Migration */}
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-2xl p-10 border border-blue-200 flex flex-col">
|
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||||
<div className="text-center flex-1 flex flex-col">
|
<div className="text-center flex-1 flex flex-col">
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||||
<span className="text-white text-2xl">🔄</span>
|
<ArrowPathIcon className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
|
<h3 className="text-2xl font-bold text-gray-900 mb-4">Existing Customers</h3>
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||||
Already have an account with us? Migrate to our new, improved portal to enjoy
|
Migrate to our new portal and enjoy enhanced security with modern interface.
|
||||||
enhanced features, better security, and a modern interface.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 text-center">
|
|
||||||
Migration Benefits:
|
|
||||||
</h4>
|
|
||||||
<div className="max-w-xs mx-auto">
|
|
||||||
<ul className="text-sm text-gray-600 space-y-2 text-left">
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Keep all your existing services and billing history</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Enhanced security and performance</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-blue-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Modern, mobile-friendly interface</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/link-whmcs"
|
href="/auth/link-whmcs"
|
||||||
className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-colors font-semibold text-lg mb-3"
|
className="block bg-blue-600 text-white px-8 py-4 rounded-xl hover:bg-blue-700 transition-all duration-300 font-semibold text-lg mb-3 shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
Migrate Your Account
|
Migrate Your Account
|
||||||
</Link>
|
</Link>
|
||||||
@ -118,41 +103,20 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Portal Users */}
|
{/* Portal Users */}
|
||||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-2xl p-10 border border-green-200 flex flex-col">
|
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||||
<div className="text-center flex-1 flex flex-col">
|
<div className="text-center flex-1 flex flex-col">
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-green-600 rounded-full mb-6 mx-auto">
|
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||||
<span className="text-white text-2xl">👤</span>
|
<UserIcon className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
|
<h3 className="text-2xl font-bold text-gray-900 mb-4">Portal Users</h3>
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||||
Already migrated or have a new portal account? Sign in to access your dashboard,
|
Sign in to access your dashboard and manage all your services efficiently.
|
||||||
manage services, and view billing.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 text-center">Portal Features:</h4>
|
|
||||||
<div className="max-w-xs mx-auto">
|
|
||||||
<ul className="text-sm text-gray-600 space-y-2 text-left">
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Real-time service status and usage</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Online billing and payment management</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-green-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>24/7 support ticket system</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/login"
|
href="/auth/login"
|
||||||
className="block bg-green-600 text-white px-8 py-4 rounded-xl hover:bg-green-700 transition-colors font-semibold text-lg mb-3"
|
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||||
>
|
>
|
||||||
Login to Portal
|
Login to Portal
|
||||||
</Link>
|
</Link>
|
||||||
@ -162,43 +126,20 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New Customers */}
|
{/* New Customers */}
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-2xl p-10 border border-purple-200 flex flex-col">
|
<div className="bg-white rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-2 border border-gray-100">
|
||||||
<div className="text-center flex-1 flex flex-col">
|
<div className="text-center flex-1 flex flex-col">
|
||||||
<div className="flex items-center justify-center w-16 h-16 bg-purple-600 rounded-full mb-6 mx-auto">
|
<div className="flex items-center justify-center w-16 h-16 bg-blue-600 rounded-full mb-6 mx-auto">
|
||||||
<span className="text-white text-2xl">✨</span>
|
<SparklesIcon className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
|
<h3 className="text-2xl font-bold text-gray-900 mb-4">New Customers</h3>
|
||||||
<p className="text-gray-600 mb-6 leading-relaxed">
|
<p className="text-gray-600 mb-6 leading-relaxed">
|
||||||
Ready to get started with our services? Create your account to access our full
|
Create your account and access our full range of IT solutions and services.
|
||||||
range of IT solutions.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl p-6 mb-6 flex-1">
|
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 text-center">
|
|
||||||
Get Started With:
|
|
||||||
</h4>
|
|
||||||
<div className="max-w-xs mx-auto">
|
|
||||||
<ul className="text-sm text-gray-600 space-y-2 text-left">
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Internet & connectivity solutions</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Business IT services</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center">
|
|
||||||
<span className="w-1.5 h-1.5 bg-purple-600 rounded-full mr-3 flex-shrink-0"></span>
|
|
||||||
<span>Professional support</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
<Link
|
<Link
|
||||||
href="/auth/signup"
|
href="/auth/signup"
|
||||||
className="block bg-purple-600 text-white px-8 py-4 rounded-xl hover:bg-purple-700 transition-colors font-semibold text-lg mb-3"
|
className="block border-2 border-blue-600 text-blue-600 px-8 py-4 rounded-xl hover:bg-blue-600 hover:text-white transition-all duration-300 font-semibold text-lg mb-3"
|
||||||
>
|
>
|
||||||
Create Account
|
Create Account
|
||||||
</Link>
|
</Link>
|
||||||
@ -211,8 +152,8 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Portal Features Section */}
|
{/* Portal Features Section */}
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">Portal Features</h2>
|
||||||
<p className="text-lg text-gray-600">
|
<p className="text-lg text-gray-600">
|
||||||
@ -221,28 +162,36 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<div className="bg-white rounded-lg p-6 text-center">
|
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<div className="text-3xl mb-3">💳</div>
|
<div className="flex justify-center mb-3">
|
||||||
|
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Billing & Payments</h3>
|
<h3 className="text-lg font-semibold mb-2 text-gray-900">Billing & Payments</h3>
|
||||||
<p className="text-gray-600 text-sm">
|
<p className="text-gray-600 text-sm">
|
||||||
View invoices, payment history, and manage billing
|
View invoices, payment history, and manage billing
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg p-6 text-center">
|
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<div className="text-3xl mb-3">⚙️</div>
|
<div className="flex justify-center mb-3">
|
||||||
|
<Cog6ToothIcon className="w-8 h-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Service Management</h3>
|
<h3 className="text-lg font-semibold mb-2 text-gray-900">Service Management</h3>
|
||||||
<p className="text-gray-600 text-sm">Control and configure your active services</p>
|
<p className="text-gray-600 text-sm">Control and configure your active services</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg p-6 text-center">
|
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<div className="text-3xl mb-3">📞</div>
|
<div className="flex justify-center mb-3">
|
||||||
|
<PhoneIcon className="w-8 h-8 text-pink-600" />
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Support Tickets</h3>
|
<h3 className="text-lg font-semibold mb-2 text-gray-900">Support Tickets</h3>
|
||||||
<p className="text-gray-600 text-sm">Create and track support requests</p>
|
<p className="text-gray-600 text-sm">Create and track support requests</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg p-6 text-center">
|
<div className="bg-white rounded-lg p-6 text-center shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
<div className="text-3xl mb-3">📊</div>
|
<div className="flex justify-center mb-3">
|
||||||
|
<ChartBarIcon className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2 text-gray-900">Usage Reports</h3>
|
<h3 className="text-lg font-semibold mb-2 text-gray-900">Usage Reports</h3>
|
||||||
<p className="text-gray-600 text-sm">Monitor service usage and performance</p>
|
<p className="text-gray-600 text-sm">Monitor service usage and performance</p>
|
||||||
</div>
|
</div>
|
||||||
@ -250,79 +199,66 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Support Section */}
|
||||||
<section className="py-16 bg-gray-50">
|
<section className="py-16">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">Need Help?</h2>
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">Need Help?</h2>
|
||||||
<p className="text-lg text-gray-600">Our support team is here to assist you</p>
|
<p className="text-lg text-gray-600">Our support team is here to assist you</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="text-center">
|
{/* Contact Details Box */}
|
||||||
<div className="w-12 h-12 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||||
<span className="text-white text-xl">📞</span>
|
<h3 className="text-xl font-semibold mb-6 text-gray-900">Contact Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">Phone Support</h4>
|
||||||
|
<p className="text-gray-600 text-sm mb-1">9:30-18:00 JST</p>
|
||||||
|
<p className="text-blue-600 font-medium">0120-660-470</p>
|
||||||
|
<p className="text-gray-500 text-sm">Toll Free within Japan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">Email Support</h4>
|
||||||
|
<p className="text-gray-600 text-sm mb-1">Response within 24h</p>
|
||||||
|
<Link href="/contact" className="text-purple-600 font-medium hover:text-purple-700">
|
||||||
|
Send Message
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Phone Support</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-2">9:30-18:00 JST</p>
|
|
||||||
<p className="text-blue-600 font-medium">0120-660-470</p>
|
|
||||||
<p className="text-gray-500 text-sm">Toll Free within Japan</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
{/* Live Chat & Business Hours Box */}
|
||||||
<div className="w-12 h-12 bg-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="bg-white rounded-lg p-8 shadow-lg hover:shadow-xl transition-all duration-300">
|
||||||
<span className="text-white text-xl">💬</span>
|
<h3 className="text-xl font-semibold mb-6 text-gray-900">Live Chat & Business Hours</h3>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<h3 className="text-lg font-semibold mb-2">Live Chat</h3>
|
<div className="text-center">
|
||||||
<p className="text-gray-600 text-sm mb-2">Available 24/7</p>
|
<h4 className="font-semibold text-gray-900 mb-2">Live Chat</h4>
|
||||||
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
|
<p className="text-gray-600 text-sm mb-1">Available 24/7</p>
|
||||||
Start Chat
|
<Link href="/chat" className="text-green-600 font-medium hover:text-green-700">
|
||||||
</Link>
|
Start Chat
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
<div>
|
||||||
<div className="w-12 h-12 bg-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
<h4 className="font-semibold text-gray-900 mb-3">Business Hours</h4>
|
||||||
<span className="text-white text-xl">✉️</span>
|
<div className="text-gray-600 text-sm space-y-1">
|
||||||
|
<p><strong>Monday - Saturday:</strong><br />10:00 AM - 6:00 PM JST</p>
|
||||||
|
<p><strong>Sunday:</strong><br />Closed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Email Support</h3>
|
|
||||||
<p className="text-gray-600 text-sm mb-2">Response within 24h</p>
|
|
||||||
<Link href="/contact" className="text-purple-600 font-medium hover:text-purple-700">
|
|
||||||
Send Message
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-900 text-white py-8">
|
<footer className="bg-white/90 backdrop-blur-sm text-gray-900 py-8 border-t border-gray-200">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
<div className="text-center">
|
||||||
<div className="flex items-center space-x-3 mb-4 md:mb-0">
|
<p className="text-gray-600 text-sm">
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
|
||||||
<span className="text-white font-bold text-sm">AS</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-semibold">Assist Solutions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-6 text-sm">
|
|
||||||
<Link href="/portal" className="hover:text-blue-400">
|
|
||||||
Portal
|
|
||||||
</Link>
|
|
||||||
<Link href="/support" className="hover:text-blue-400">
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
<Link href="/about" className="hover:text-blue-400">
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
<Link href="/contact" className="hover:text-blue-400">
|
|
||||||
Contact
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center">
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
© 2025 Assist Solutions Corp. All Rights Reserved.
|
© 2025 Assist Solutions Corp. All Rights Reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
|
import { Logo } from "@/components/ui/logo";
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
@ -33,6 +34,7 @@ interface NavigationItem {
|
|||||||
href?: string;
|
href?: string;
|
||||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
children?: NavigationChild[];
|
children?: NavigationChild[];
|
||||||
|
isLogout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@ -66,13 +68,14 @@ const navigation = [
|
|||||||
{ name: "Notifications", href: "/account/notifications" },
|
{ name: "Notifications", href: "/account/notifications" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { user, isAuthenticated, logout, checkAuth } = useAuthStore();
|
const { user, isAuthenticated, checkAuth } = useAuthStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -94,12 +97,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
void logout().then(() => {
|
|
||||||
router.push("/");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state until mounted and auth is checked
|
// Show loading state until mounted and auth is checked
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
@ -156,10 +153,10 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-col w-0 flex-1 overflow-hidden">
|
<div className="flex flex-col w-0 flex-1 overflow-hidden">
|
||||||
{/* Top navigation */}
|
{/* Top navigation */}
|
||||||
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow">
|
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden"
|
className="px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
<Bars3Icon className="h-6 w-6" />
|
<Bars3Icon className="h-6 w-6" />
|
||||||
@ -168,13 +165,22 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
<div className="flex-1 flex">
|
<div className="flex-1 flex">
|
||||||
<div className="w-full flex md:ml-0">
|
<div className="w-full flex md:ml-0">
|
||||||
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
|
<div className="relative w-full text-gray-400 focus-within:text-gray-600">
|
||||||
<div className="flex items-center h-16">
|
<div className="flex items-center h-16"></div>
|
||||||
<h1 className="text-lg font-semibold text-gray-900">Assist Solutions Portal</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex items-center md:ml-6">
|
<div className="ml-4 flex items-center md:ml-6">
|
||||||
|
{/* Profile name */}
|
||||||
|
<div className="text-right mr-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user?.firstName
|
||||||
|
? user.firstName
|
||||||
|
: user?.email?.split("@")[0] || "User"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -182,29 +188,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||||||
>
|
>
|
||||||
<BellIcon className="h-6 w-6" />
|
<BellIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Profile dropdown */}
|
|
||||||
<div className="ml-3 relative">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{user?.firstName && user?.lastName
|
|
||||||
? `${user.firstName} ${user.lastName}`
|
|
||||||
: user?.firstName
|
|
||||||
? user.firstName
|
|
||||||
: user?.email?.split("@")[0] || "User"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">{user?.email || ""}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="bg-white p-1 rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
title="Sign out"
|
|
||||||
>
|
|
||||||
<ArrowRightStartOnRectangleIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -228,13 +211,11 @@ function DesktopSidebar({
|
|||||||
toggleExpanded: (name: string) => void;
|
toggleExpanded: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
|
<div className="flex flex-col h-0 flex-1 bg-white">
|
||||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
<div className="flex items-center flex-shrink-0 px-4">
|
<div className="flex items-center flex-shrink-0 px-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
<Logo size={32} />
|
||||||
<span className="text-white font-bold text-sm">AS</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-semibold text-gray-900">Portal</span>
|
<span className="text-lg font-semibold text-gray-900">Portal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -266,13 +247,11 @@ function MobileSidebar({
|
|||||||
toggleExpanded: (name: string) => void;
|
toggleExpanded: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-0 flex-1 border-r border-gray-200 bg-white">
|
<div className="flex flex-col h-0 flex-1 bg-white">
|
||||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||||
<div className="flex items-center flex-shrink-0 px-4">
|
<div className="flex items-center flex-shrink-0 px-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
<Logo size={32} />
|
||||||
<span className="text-white font-bold text-sm">AS</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-semibold text-gray-900">Portal</span>
|
<span className="text-lg font-semibold text-gray-900">Portal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -303,6 +282,9 @@ function NavigationItem({
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
toggleExpanded: (name: string) => void;
|
toggleExpanded: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { logout } = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isActive = hasChildren
|
const isActive = hasChildren
|
||||||
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
|
? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false
|
||||||
@ -310,6 +292,12 @@ function NavigationItem({
|
|||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
void logout().then(() => {
|
||||||
|
router.push("/");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
if (hasChildren) {
|
if (hasChildren) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -356,6 +344,18 @@ function NavigationItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.isLogout) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="group flex items-center px-2 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-md w-full text-left"
|
||||||
|
>
|
||||||
|
<item.icon className="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6" />
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={item.href || "#"}
|
href={item.href || "#"}
|
||||||
|
|||||||
60
apps/portal/src/components/ui/logo.tsx
Normal file
60
apps/portal/src/components/ui/logo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -101,9 +101,11 @@ export function usePaymentMethods() {
|
|||||||
|
|
||||||
return authenticatedApi.get<PaymentMethodList>(`/invoices/payment-methods`);
|
return authenticatedApi.get<PaymentMethodList>(`/invoices/payment-methods`);
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 1 * 60 * 1000, // Reduced to 1 minute for better refresh
|
||||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
gcTime: 5 * 60 * 1000, // Reduced to 5 minutes
|
||||||
enabled: isAuthenticated && !!token,
|
enabled: isAuthenticated && !!token,
|
||||||
|
retry: 3, // Retry failed requests
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export function getCurrencySymbol(currencyCode: string): string {
|
|||||||
USD: "$",
|
USD: "$",
|
||||||
EUR: "€",
|
EUR: "€",
|
||||||
GBP: "£",
|
GBP: "£",
|
||||||
JPY: "¥",
|
JPY: "¥",
|
||||||
CAD: "C$",
|
CAD: "C$",
|
||||||
AUD: "A$",
|
AUD: "A$",
|
||||||
CNY: "¥",
|
CNY: "¥",
|
||||||
@ -85,7 +85,7 @@ export function getCurrencySymbol(currencyCode: string): string {
|
|||||||
NZD: "NZ$",
|
NZD: "NZ$",
|
||||||
};
|
};
|
||||||
|
|
||||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
|
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "¥";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
246
docs/SIGNUP_VALIDATION_RULES.md
Normal file
246
docs/SIGNUP_VALIDATION_RULES.md
Normal 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
|
||||||
@ -48,6 +48,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@eslint/js": "^9.34.0",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
"eslint": "^9.33.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-config-next": "15.5.0",
|
"eslint-config-next": "15.5.0",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface Invoice {
|
|||||||
number: string;
|
number: string;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
currency: string; // e.g. 'USD', 'JPY'
|
currency: string; // e.g. 'USD', 'JPY'
|
||||||
currencySymbol?: string; // e.g. '$', '¥'
|
currencySymbol?: string; // e.g. '¥', '¥'
|
||||||
total: number; // decimal as number
|
total: number; // decimal as number
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
tax: number;
|
tax: number;
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export interface Subscription {
|
|||||||
nextDue?: string; // ISO
|
nextDue?: string; // ISO
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
currencySymbol?: string; // e.g., '¥', '$'
|
currencySymbol?: string; // e.g., '¥', '¥'
|
||||||
registrationDate: string; // ISO
|
registrationDate: string; // ISO
|
||||||
notes?: string;
|
notes?: string;
|
||||||
customFields?: Record<string, string>;
|
customFields?: Record<string, string>;
|
||||||
|
|||||||
@ -120,8 +120,8 @@ export function sanitizeString(input: string): string {
|
|||||||
/**
|
/**
|
||||||
* Formats currency amount
|
* Formats currency amount
|
||||||
*/
|
*/
|
||||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
export function formatCurrency(amount: number, currency = "JPY"): string {
|
||||||
return new Intl.NumberFormat("en-US", {
|
return new Intl.NumberFormat("ja-JP", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency,
|
currency,
|
||||||
}).format(amount);
|
}).format(amount);
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -15,6 +15,9 @@ importers:
|
|||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
'@eslint/js':
|
||||||
|
specifier: ^9.34.0
|
||||||
|
version: 9.34.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.3.0
|
specifier: ^24.3.0
|
||||||
version: 24.3.0
|
version: 24.3.0
|
||||||
@ -548,6 +551,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
|
resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@eslint/js@9.34.0':
|
||||||
|
resolution: {integrity: sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==}
|
||||||
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.6':
|
'@eslint/object-schema@2.1.6':
|
||||||
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
|
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@ -5288,6 +5295,8 @@ snapshots:
|
|||||||
|
|
||||||
'@eslint/js@9.33.0': {}
|
'@eslint/js@9.33.0': {}
|
||||||
|
|
||||||
|
'@eslint/js@9.34.0': {}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.6': {}
|
'@eslint/object-schema@2.1.6': {}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.3.5':
|
'@eslint/plugin-kit@0.3.5':
|
||||||
|
|||||||
@ -39,13 +39,13 @@ start_services() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Start PostgreSQL and Redis
|
# Start PostgreSQL and Redis
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d postgres redis
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" up -d postgres redis
|
||||||
|
|
||||||
# Wait for database
|
# Wait for database
|
||||||
log "⏳ Waiting for database to be ready..."
|
log "⏳ Waiting for database to be ready..."
|
||||||
timeout=30
|
timeout=30
|
||||||
while [ $timeout -gt 0 ]; do
|
while [ $timeout -gt 0 ]; do
|
||||||
if docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" exec -T postgres pg_isready -U dev -d portal_dev 2>/dev/null; then
|
if docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" exec -T postgres pg_isready -U dev -d portal_dev 2>/dev/null; then
|
||||||
log "✅ Database is ready!"
|
log "✅ Database is ready!"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@ -65,7 +65,7 @@ start_services() {
|
|||||||
# Start with admin tools
|
# Start with admin tools
|
||||||
start_with_tools() {
|
start_with_tools() {
|
||||||
log "🛠️ Starting development services with admin tools..."
|
log "🛠️ Starting development services with admin tools..."
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" --profile tools up -d
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" --profile tools up -d
|
||||||
|
|
||||||
log "🔗 Database Admin: http://localhost:8080"
|
log "🔗 Database Admin: http://localhost:8080"
|
||||||
log "🔗 Redis Commander: http://localhost:8081"
|
log "🔗 Redis Commander: http://localhost:8081"
|
||||||
@ -74,19 +74,19 @@ start_with_tools() {
|
|||||||
# Stop services
|
# Stop services
|
||||||
stop_services() {
|
stop_services() {
|
||||||
log "⏹️ Stopping development services..."
|
log "⏹️ Stopping development services..."
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down
|
||||||
log "✅ Services stopped"
|
log "✅ Services stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show status
|
# Show status
|
||||||
show_status() {
|
show_status() {
|
||||||
log "📊 Development Services Status:"
|
log "📊 Development Services Status:"
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show logs
|
# Show logs
|
||||||
show_logs() {
|
show_logs() {
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs -f "${@:2}"
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" logs -f "${@:2}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start apps (services + local development)
|
# Start apps (services + local development)
|
||||||
@ -94,7 +94,7 @@ start_apps() {
|
|||||||
log "🚀 Starting development services and applications..."
|
log "🚀 Starting development services and applications..."
|
||||||
|
|
||||||
# Start services if not running
|
# Start services if not running
|
||||||
if ! docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps | grep -q "Up"; then
|
if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps | grep -q "Up"; then
|
||||||
start_services
|
start_services
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ start_apps() {
|
|||||||
reset_env() {
|
reset_env() {
|
||||||
log "🔄 Resetting development environment..."
|
log "🔄 Resetting development environment..."
|
||||||
stop_services
|
stop_services
|
||||||
docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v
|
docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" down -v
|
||||||
docker system prune -f
|
docker system prune -f
|
||||||
log "✅ Development environment reset"
|
log "✅ Development environment reset"
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ reset_env() {
|
|||||||
migrate_db() {
|
migrate_db() {
|
||||||
log "🗄️ Running database migrations..."
|
log "🗄️ Running database migrations..."
|
||||||
|
|
||||||
if ! docker-compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps postgres | grep -q "Up"; then
|
if ! docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME" ps postgres | grep -q "Up"; then
|
||||||
error "Database service not running. Run 'pnpm dev:start' first"
|
error "Database service not running. Run 'pnpm dev:start' first"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user