Enhance authentication and password management features

- Added new endpoint for retrieving account status by email in AuthController.
- Implemented change password functionality with validation in AuthService.
- Updated password strength validation to require special characters across relevant DTOs.
- Introduced optional API Access Key in environment configuration for WHMCS.
- Refactored user address update logic in UsersController to improve clarity and maintainability.
- Enhanced error handling in various services to provide more user-friendly messages.
- Updated frontend components to support new password change and account status features.
This commit is contained in:
T. Narantuya 2025-09-02 13:52:13 +09:00
parent 0f7d680782
commit cc2a6a3046
42 changed files with 1493 additions and 569 deletions

View File

@ -47,6 +47,8 @@ CORS_ORIGIN="http://localhost:3000"
WHMCS_BASE_URL="https://accounts.asolutions.co.jp" WHMCS_BASE_URL="https://accounts.asolutions.co.jp"
WHMCS_API_IDENTIFIER="your_whmcs_api_identifier" WHMCS_API_IDENTIFIER="your_whmcs_api_identifier"
WHMCS_API_SECRET="your_whmcs_api_secret" WHMCS_API_SECRET="your_whmcs_api_secret"
# Optional: Some deployments require an API Access Key in addition to credentials
# WHMCS_API_ACCESS_KEY="your_whmcs_api_access_key"
# Salesforce Integration (use your actual credentials) # Salesforce Integration (use your actual credentials)
SF_LOGIN_URL="https://asolutions.my.salesforce.com" SF_LOGIN_URL="https://asolutions.my.salesforce.com"
@ -90,4 +92,3 @@ NODE_OPTIONS="--no-deprecation"
# 2. Frontend and Backend run locally (outside containers) for hot-reloading # 2. Frontend and Backend run locally (outside containers) for hot-reloading
# 3. Only database and cache services run in containers # 3. Only database and cache services run in containers

View File

@ -8,9 +8,14 @@ import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { SignupDto } from "./dto/signup.dto"; import { SignupDto } from "./dto/signup.dto";
import { RequestPasswordResetDto } from "./dto/request-password-reset.dto"; import { RequestPasswordResetDto } from "./dto/request-password-reset.dto";
import { ResetPasswordDto } from "./dto/reset-password.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto";
import { ChangePasswordDto } from "./dto/change-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 { ValidateSignupDto } from "./dto/validate-signup.dto";
import {
AccountStatusRequestDto,
AccountStatusResponseDto,
} from "./dto/account-status.dto";
import { Public } from "./decorators/public.decorator"; import { Public } from "./decorators/public.decorator";
@ApiTags("auth") @ApiTags("auth")
@ -39,6 +44,16 @@ export class AuthController {
return this.authService.healthCheck(); return this.authService.healthCheck();
} }
@Public()
@Post("account-status")
@ApiOperation({ summary: "Get account status by email" })
@ApiResponse({ status: 200, description: "Account status", type: Object })
async accountStatus(
@Body() body: AccountStatusRequestDto
): Promise<AccountStatusResponseDto> {
return this.authService.getAccountStatus(body.email);
}
@Public() @Public()
@Post("signup") @Post("signup")
@UseGuards(AuthThrottleGuard) @UseGuards(AuthThrottleGuard)
@ -131,6 +146,17 @@ export class AuthController {
return this.authService.resetPassword(body.token, body.password); return this.authService.resetPassword(body.token, body.password);
} }
@Post("change-password")
@Throttle({ default: { limit: 5, ttl: 300000 } })
@ApiOperation({ summary: "Change password (authenticated)" })
@ApiResponse({ status: 200, description: "Password changed successfully" })
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordDto
) {
return this.authService.changePassword(req.user.id, body.currentPassword, body.newPassword);
}
@Get("me") @Get("me")
@ApiOperation({ summary: "Get current authentication status" }) @ApiOperation({ summary: "Get current authentication status" })
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) { getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {

View File

@ -3,6 +3,7 @@ import {
UnauthorizedException, UnauthorizedException,
ConflictException, ConflictException,
BadRequestException, BadRequestException,
NotFoundException,
Inject, Inject,
} from "@nestjs/common"; } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
@ -199,18 +200,25 @@ export class AuthService {
// Enhanced input validation // Enhanced input validation
this.validateSignupData(signupData); this.validateSignupData(signupData);
// Check if user already exists // Check if a portal user already exists
const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email); const existingUser: PrismaUser | null = await this.usersService.findByEmailInternal(email);
if (existingUser) { if (existingUser) {
// Determine whether the user has a completed mapping (registered) or not
const mapped = await this.mappingsService.hasMapping(existingUser.id);
const message = mapped
? "You already have an account. Please sign in."
: "You already have an account with us. Please sign in to continue setup.";
await this.auditService.logAuthEvent( await this.auditService.logAuthEvent(
AuditAction.SIGNUP, AuditAction.SIGNUP,
undefined, existingUser.id,
{ email, reason: "User already exists" }, { email, reason: mapped ? "mapped_user_exists" : "unmapped_user_exists" },
request, request,
false, false,
"User with this email already exists" message
); );
throw new ConflictException("User with this email already exists"); throw new ConflictException(message);
} }
// Hash password with environment-based configuration // Hash password with environment-based configuration
@ -246,6 +254,30 @@ export class AuthService {
// 2. Create client in WHMCS // 2. Create client in WHMCS
let whmcsClient: { clientId: number }; let whmcsClient: { clientId: number };
try { try {
// 2.0 Pre-check for existing WHMCS client by email to avoid duplicates and guide UX
try {
const existingWhmcs = await this.whmcsService.getClientDetailsByEmail(email);
if (existingWhmcs) {
// If a mapping already exists for this WHMCS client, user should sign in
const existingMapping = await this.mappingsService.findByWhmcsClientId(
existingWhmcs.id
);
if (existingMapping) {
throw new ConflictException("You already have an account. Please sign in.");
}
// Otherwise, instruct to link the existing billing account instead of creating a new one
throw new ConflictException(
"We found an existing billing account for this email. Please link your account instead."
);
}
} catch (pre) {
// Continue only if the client was not found; rethrow other errors
if (!(pre instanceof NotFoundException)) {
throw pre;
}
}
// Prepare WHMCS custom fields (IDs configurable via env) // Prepare WHMCS custom fields (IDs configurable via env)
const customerNumberFieldId = this.configService.get<string>( const customerNumberFieldId = this.configService.get<string>(
"WHMCS_CUSTOMER_NUMBER_FIELD_ID", "WHMCS_CUSTOMER_NUMBER_FIELD_ID",
@ -450,6 +482,21 @@ export class AuthService {
throw new UnauthorizedException("WHMCS client not found with this email address"); throw new UnauthorizedException("WHMCS client not found with this email address");
} }
// 1.a If this WHMCS client is already mapped, direct the user to sign in instead
try {
const existingMapping = await this.mappingsService.findByWhmcsClientId(
clientDetails.id
);
if (existingMapping) {
throw new ConflictException(
"This billing account is already linked. Please sign in."
);
}
} catch (mapErr) {
if (mapErr instanceof ConflictException) throw mapErr;
// ignore not-found mapping cases; proceed with linking
}
// 2. Validate the password using ValidateLogin // 2. Validate the password using ValidateLogin
try { try {
this.logger.debug(`About to validate WHMCS password for ${email}`); this.logger.debug(`About to validate WHMCS password for ${email}`);
@ -822,6 +869,125 @@ export class AuthService {
} }
} }
async getAccountStatus(email: string) {
// Normalize email
const normalized = email?.toLowerCase().trim();
if (!normalized || !normalized.includes("@")) {
throw new BadRequestException("Valid email is required");
}
let portalUser: PrismaUser | null = null;
let mapped = false;
let whmcsExists = false;
let needsPasswordSet = false;
try {
portalUser = await this.usersService.findByEmailInternal(normalized);
if (portalUser) {
mapped = await this.mappingsService.hasMapping(portalUser.id);
needsPasswordSet = !portalUser.passwordHash;
}
} catch (e) {
this.logger.warn("Account status: portal lookup failed", { error: getErrorMessage(e) });
}
// If already mapped, we can assume a WHMCS client exists
if (mapped) {
whmcsExists = true;
} else {
// Try a direct WHMCS lookup by email (best-effort)
try {
const client = await this.whmcsService.getClientDetailsByEmail(normalized);
whmcsExists = !!client;
} catch (e) {
// Treat not found as no; other errors as unknown (leave whmcsExists false)
this.logger.debug("Account status: WHMCS lookup", { error: getErrorMessage(e) });
}
}
let state: "none" | "portal_only" | "whmcs_only" | "both_mapped" = "none";
if (portalUser && mapped) state = "both_mapped";
else if (portalUser) state = "portal_only";
else if (whmcsExists) state = "whmcs_only";
const recommendedAction = (() => {
switch (state) {
case "both_mapped":
return "sign_in" as const;
case "portal_only":
return needsPasswordSet ? ("set_password" as const) : ("sign_in" as const);
case "whmcs_only":
return "link_account" as const;
case "none":
default:
return "sign_up" as const;
}
})();
return {
state,
portalUserExists: !!portalUser,
whmcsClientExists: whmcsExists,
mapped,
needsPasswordSet,
recommendedAction,
};
}
async changePassword(userId: string, currentPassword: string, newPassword: string) {
// Fetch raw user with passwordHash
const user = await this.usersService.findByIdInternal(userId);
if (!user) {
throw new UnauthorizedException("User not found");
}
if (!user.passwordHash) {
throw new BadRequestException("No password set. Please set a password first.");
}
const isCurrentValid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!isCurrentValid) {
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password", reason: "invalid_current_password" },
undefined,
false,
"Invalid current password"
);
throw new BadRequestException("Current password is incorrect");
}
// Validate new password strength (reusing signup policy)
if (newPassword.length < 8 || !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)) {
throw new BadRequestException(
"Password must be at least 8 characters and include uppercase, lowercase, number, and special character."
);
}
const saltRoundsConfig = this.configService.get<string | number>("BCRYPT_ROUNDS", 12);
const saltRounds =
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
const updatedUser = await this.usersService.update(user.id, { passwordHash });
await this.auditService.logAuthEvent(
AuditAction.PASSWORD_CHANGE,
user.id,
{ action: "change_password" },
undefined,
true
);
// Issue fresh tokens
const tokens = this.generateTokens(updatedUser);
return {
user: this.sanitizeUser(updatedUser),
...tokens,
};
}
private validateSignupData(signupData: SignupDto) { private validateSignupData(signupData: SignupDto) {
const { email, password, firstName, lastName } = signupData; const { email, password, firstName, lastName } = signupData;
@ -839,10 +1005,10 @@ export class AuthService {
throw new BadRequestException("Password must be at least 8 characters long."); throw new BadRequestException("Password must be at least 8 characters long.");
} }
// Password must contain at least one uppercase letter, one lowercase letter, and one number // Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) { if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(password)) {
throw new BadRequestException( throw new BadRequestException(
"Password must contain at least one uppercase letter, one lowercase letter, and one number." "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character."
); );
} }
} }

View File

@ -0,0 +1,21 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail } from "class-validator";
export class AccountStatusRequestDto {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
}
export type AccountState = "none" | "portal_only" | "whmcs_only" | "both_mapped";
export type RecommendedAction = "sign_up" | "sign_in" | "link_account" | "set_password";
export interface AccountStatusResponseDto {
state: AccountState;
portalUserExists: boolean;
whmcsClientExists: boolean;
mapped: boolean;
needsPasswordSet?: boolean;
recommendedAction: RecommendedAction;
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, MinLength, Matches } from "class-validator";
export class ChangePasswordDto {
@ApiProperty({ example: "CurrentPassword123!" })
@IsString()
@MinLength(1)
currentPassword!: string;
@ApiProperty({ example: "NewSecurePassword123!" })
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
newPassword!: string;
}

View File

@ -9,6 +9,6 @@ export class ResetPasswordDto {
@ApiProperty({ example: "SecurePassword123!" }) @ApiProperty({ example: "SecurePassword123!" })
@IsString() @IsString()
@MinLength(8) @MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/) @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/)
password!: string; password!: string;
} }

View File

@ -13,7 +13,7 @@ export class SetPasswordDto {
}) })
@IsString() @IsString()
@MinLength(8, { message: "Password must be at least 8 characters long" }) @MinLength(8, { message: "Password must be at least 8 characters long" })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
message: message:
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
}) })

View File

@ -55,7 +55,7 @@ export class SignupDto {
}) })
@IsString() @IsString()
@MinLength(8, { message: "Password must be at least 8 characters long" }) @MinLength(8, { message: "Password must be at least 8 characters long" })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/, {
message: message:
"Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character", "Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character",
}) })

View File

@ -33,6 +33,7 @@ export const envSchema = z.object({
WHMCS_BASE_URL: z.string().url().optional(), WHMCS_BASE_URL: z.string().url().optional(),
WHMCS_API_IDENTIFIER: z.string().optional(), WHMCS_API_IDENTIFIER: z.string().optional(),
WHMCS_API_SECRET: z.string().optional(), WHMCS_API_SECRET: z.string().optional(),
WHMCS_API_ACCESS_KEY: z.string().optional(),
WHMCS_WEBHOOK_SECRET: z.string().optional(), WHMCS_WEBHOOK_SECRET: z.string().optional(),
// Salesforce Configuration // Salesforce Configuration

View File

@ -9,6 +9,33 @@ import {
} from "class-validator"; } from "class-validator";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
// Allow order payload to include an address snapshot override
export class OrderAddress {
@IsOptional()
@IsString()
street?: string | null;
@IsOptional()
@IsString()
streetLine2?: string | null;
@IsOptional()
@IsString()
city?: string | null;
@IsOptional()
@IsString()
state?: string | null;
@IsOptional()
@IsString()
postalCode?: string | null;
@IsOptional()
@IsString()
country?: string | null;
}
export class OrderConfigurations { export class OrderConfigurations {
// Activation (All order types) // Activation (All order types)
@IsOptional() @IsOptional()
@ -79,6 +106,12 @@ export class OrderConfigurations {
portingDateOfBirth?: string; portingDateOfBirth?: string;
// VPN region is inferred from product VPN_Region__c field, no user input needed // VPN region is inferred from product VPN_Region__c field, no user input needed
// Optional address override captured at checkout
@IsOptional()
@ValidateNested()
@Type(() => OrderAddress)
address?: OrderAddress;
} }
export class CreateOrderDto { export class CreateOrderDto {

View File

@ -0,0 +1,41 @@
import { IsOptional, IsString, Length } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class UpdateAddressDto {
@ApiProperty({ description: "Street address", required: false })
@IsOptional()
@IsString()
@Length(0, 200)
street?: string;
@ApiProperty({ description: "Street address line 2", required: false })
@IsOptional()
@IsString()
@Length(0, 200)
streetLine2?: string;
@ApiProperty({ description: "City", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
city?: string;
@ApiProperty({ description: "State/Prefecture", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
state?: string;
@ApiProperty({ description: "Postal code", required: false })
@IsOptional()
@IsString()
@Length(0, 20)
postalCode?: string;
@ApiProperty({ description: "Country (ISO alpha-2)", required: false })
@IsOptional()
@IsString()
@Length(0, 100)
country?: string;
}

View File

@ -10,7 +10,8 @@ import {
import { UsersService } from "./users.service"; import { UsersService } from "./users.service";
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger"; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
import * as UserDto from "./dto/update-user.dto"; import * as UserDto from "./dto/update-user.dto";
import * as BillingDto from "./dto/update-billing.dto"; // import * as BillingDto from "./dto/update-billing.dto"; // No longer exposed as an endpoint
import { UpdateAddressDto } from "./dto/update-address.dto";
import type { RequestWithUser } from "../auth/auth.types"; import type { RequestWithUser } from "../auth/auth.types";
@ApiTags("users") @ApiTags("users")
@ -53,15 +54,16 @@ export class UsersController {
return this.usersService.getBillingInfo(req.user.id); return this.usersService.getBillingInfo(req.user.id);
} }
@Patch("billing") // Removed PATCH /me/billing in favor of PATCH /me/address to keep address updates explicit.
@ApiOperation({ summary: "Update billing information" })
@ApiResponse({ status: 200, description: "Billing information updated successfully" }) @Patch("address")
@ApiOperation({ summary: "Update mailing address" })
@ApiResponse({ status: 200, description: "Address updated successfully" })
@ApiResponse({ status: 400, description: "Invalid input data" }) @ApiResponse({ status: 400, description: "Invalid input data" })
@ApiResponse({ status: 401, description: "Unauthorized" }) @ApiResponse({ status: 401, description: "Unauthorized" })
async updateBilling( async updateAddress(@Req() req: RequestWithUser, @Body() address: UpdateAddressDto) {
@Req() req: RequestWithUser, await this.usersService.updateAddress(req.user.id, address);
@Body() billingData: BillingDto.UpdateBillingDto // Return fresh billing info snapshot (source of truth from WHMCS)
) { return this.usersService.getBillingInfo(req.user.id);
return this.usersService.updateBillingInfo(req.user.id, billingData);
} }
} }

View File

@ -1,5 +1,6 @@
import { getErrorMessage } from "../common/utils/error.util"; import { getErrorMessage } from "../common/utils/error.util";
import { Injectable, Inject, NotFoundException } from "@nestjs/common"; import type { UpdateAddressDto } from "./dto/update-address.dto";
import { Injectable, Inject, NotFoundException, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { PrismaService } from "../common/prisma/prisma.service"; import { PrismaService } from "../common/prisma/prisma.service";
import { User, Activity } from "@customer-portal/shared"; import { User, Activity } from "@customer-portal/shared";
@ -162,6 +163,20 @@ export class UsersService {
} }
} }
// Internal method for auth service - returns raw user by ID with sensitive fields
async findByIdInternal(id: string): Promise<PrismaUser | null> {
const validId = this.validateUserId(id);
try {
return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) {
this.logger.error("Failed to find user by ID (internal)", {
error: getErrorMessage(error),
});
throw new Error("Failed to find user");
}
}
async findById(id: string): Promise<EnhancedUser | null> { async findById(id: string): Promise<EnhancedUser | null> {
const validId = this.validateUserId(id); const validId = this.validateUserId(id);
@ -646,25 +661,40 @@ export class UsersService {
whmcsUpdateData.companyname = billingData.company; whmcsUpdateData.companyname = billingData.company;
} }
// No-op if nothing to update
if (Object.keys(whmcsUpdateData).length === 0) {
this.logger.debug({ userId }, "No billing fields provided; skipping WHMCS update");
return;
}
// Update in WHMCS (authoritative source) // Update in WHMCS (authoritative source)
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData); await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
this.logger.log({ userId }, "Successfully updated billing information in WHMCS"); this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
} catch (error) { } catch (error) {
this.logger.error({ userId, error }, "Failed to update billing information"); const msg = getErrorMessage(error);
this.logger.error({ userId, error: msg }, "Failed to update billing information");
// Provide user-friendly error message without exposing sensitive details // Surface API error details when available as 400 instead of 500
if (error instanceof Error && error.message.includes("403")) { if (msg.includes("WHMCS API Error")) {
throw new Error( throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
"Access denied. Please contact support to update your billing information."
);
} else if (error instanceof Error && error.message.includes("404")) {
throw new Error("Account not found. Please contact support.");
} else {
throw new Error(
"Unable to update billing information. Please try again later or contact support."
);
} }
if (msg.includes("HTTP ")) {
throw new BadRequestException("Upstream billing system error. Please try again.");
}
if (msg.includes("Missing required WHMCS configuration")) {
throw new BadRequestException("Billing system not configured. Please contact support.");
}
throw new BadRequestException("Unable to update billing information.");
} }
} }
/**
* Update only address fields in WHMCS (alias used by checkout)
*/
async updateAddress(userId: string, address: UpdateAddressDto): Promise<void> {
// Reuse the billing updater since WHMCS stores address on the client record
return this.updateBillingInfo(userId, address as unknown as UpdateBillingDto);
}
} }

View File

@ -186,6 +186,22 @@ export class WhmcsCacheService {
} }
} }
/**
* Invalidate client data cache for a specific client
*/
async invalidateClientCache(clientId: number): Promise<void> {
try {
const key = this.buildClientKey(clientId);
await this.cacheService.del(key);
this.logger.log(`Invalidated client cache for client ${clientId}`);
} catch (error) {
this.logger.error(`Failed to invalidate client cache for client ${clientId}`, {
error: getErrorMessage(error),
});
}
}
/** /**
* Invalidate cache by tags * Invalidate cache by tags
*/ */

View File

@ -110,8 +110,8 @@ export class WhmcsClientService {
try { try {
await this.connectionService.updateClient(clientId, updateData); await this.connectionService.updateClient(clientId, updateData);
// Invalidate cache after update // Invalidate client cache after update
await this.cacheService.invalidateUserCache(clientId.toString()); await this.cacheService.invalidateClientCache(clientId);
this.logger.log(`Successfully updated WHMCS client ${clientId}`); this.logger.log(`Successfully updated WHMCS client ${clientId}`);
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -37,6 +37,7 @@ export interface WhmcsApiConfig {
@Injectable() @Injectable()
export class WhmcsConnectionService { export class WhmcsConnectionService {
private readonly config: WhmcsApiConfig; private readonly config: WhmcsApiConfig;
private readonly accessKey?: string;
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
@ -50,6 +51,8 @@ export class WhmcsConnectionService {
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3), retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000), retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
}; };
// Optional API Access Key (used by some WHMCS deployments alongside API Credentials)
this.accessKey = this.configService.get<string | undefined>("WHMCS_API_ACCESS_KEY");
this.validateConfig(); this.validateConfig();
} }
@ -79,11 +82,19 @@ export class WhmcsConnectionService {
): Promise<T> { ): Promise<T> {
const url = `${this.config.baseUrl}/includes/api.php`; const url = `${this.config.baseUrl}/includes/api.php`;
const requestParams = { // Use WHMCS API Credential fields (identifier/secret). Do not send as username/password.
// WHMCS expects `identifier` and `secret` when authenticating with API Credentials.
const baseParams: Record<string, string> = {
action, action,
username: this.config.identifier, identifier: this.config.identifier,
password: this.config.secret, secret: this.config.secret,
responsetype: "json", responsetype: "json",
};
if (this.accessKey) {
baseParams.accesskey = this.accessKey;
}
const requestParams: Record<string, string> = {
...baseParams,
...this.sanitizeParams(params), ...this.sanitizeParams(params),
}; };
@ -101,6 +112,7 @@ export class WhmcsConnectionService {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json, text/plain, */*",
"User-Agent": "Customer-Portal/1.0", "User-Agent": "Customer-Portal/1.0",
}, },
body: formData, body: formData,
@ -109,11 +121,16 @@ export class WhmcsConnectionService {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseText = await response.text(); const responseText = await response.text();
if (!response.ok) {
// Try to include a snippet of body in the error for diagnostics
const snippet = responseText?.slice(0, 300);
const error = new Error(
`HTTP ${response.status}: ${response.statusText}${snippet ? ` | Body: ${snippet}` : ""}`
);
throw error;
}
let data: WhmcsApiResponse<T>; let data: WhmcsApiResponse<T>;
try { try {
@ -222,7 +239,15 @@ export class WhmcsConnectionService {
const sanitized = { ...params }; const sanitized = { ...params };
// Remove sensitive data from logs // Remove sensitive data from logs
const sensitiveFields = ["password", "password2", "secret", "token", "key"]; const sensitiveFields = [
"password",
"password2",
"secret",
"identifier",
"accesskey",
"token",
"key",
];
sensitiveFields.forEach(field => { sensitiveFields.forEach(field => {
if (sanitized[field]) { if (sanitized[field]) {
sanitized[field] = "[REDACTED]"; sanitized[field] = "[REDACTED]";

View File

@ -3,6 +3,8 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Disable Next DevTools to avoid manifest errors in dev
devtools: { enabled: false },
// Enable standalone output only for production deployment // Enable standalone output only for production deployment
output: process.env.NODE_ENV === "production" ? "standalone" : undefined, output: process.env.NODE_ENV === "production" ? "standalone" : undefined,

View File

@ -122,7 +122,7 @@ export default function BillingPage() {
setError(null); setError(null);
// Update address via API // Update address via API
await authenticatedApi.patch("/me/billing", { const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
street: editedAddress.street, street: editedAddress.street,
streetLine2: editedAddress.streetLine2, streetLine2: editedAddress.streetLine2,
city: editedAddress.city, city: editedAddress.city,
@ -131,14 +131,8 @@ export default function BillingPage() {
country: editedAddress.country, country: editedAddress.country,
}); });
// Update local state // Update local state from authoritative response
if (billingInfo) { setBillingInfo(updated);
setBillingInfo({
...billingInfo,
address: editedAddress,
isComplete: true,
});
}
setEditing(false); setEditing(false);
setEditedAddress(null); setEditedAddress(null);

View File

@ -60,9 +60,12 @@ export default function ProfilePage() {
const [isEditingAddress, setIsEditingAddress] = useState(false); const [isEditingAddress, setIsEditingAddress] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSavingAddress, setIsSavingAddress] = useState(false); const [isSavingAddress, setIsSavingAddress] = useState(false);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null); const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
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 [pwdError, setPwdError] = useState<string | null>(null);
const [pwdSuccess, setPwdSuccess] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: user?.firstName || "", firstName: user?.firstName || "",
@ -80,6 +83,12 @@ export default function ProfilePage() {
country: "", country: "",
}); });
const [pwdForm, setPwdForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
// Fetch billing info on component mount // Fetch billing info on component mount
useEffect(() => { useEffect(() => {
const fetchBillingInfo = async () => { const fetchBillingInfo = async () => {
@ -195,7 +204,7 @@ export default function ProfilePage() {
return; return;
} }
await authenticatedApi.patch("/me/billing", { const updated = await authenticatedApi.patch<BillingInfo>("/me/address", {
street: addressData.street, street: addressData.street,
streetLine2: addressData.streetLine2, streetLine2: addressData.streetLine2,
city: addressData.city, city: addressData.city,
@ -204,14 +213,8 @@ export default function ProfilePage() {
country: addressData.country, country: addressData.country,
}); });
// Update local state // Update local state from authoritative response
if (billingInfo) { setBillingInfo(updated);
setBillingInfo({
...billingInfo,
address: addressData,
isComplete: true,
});
}
setIsEditingAddress(false); setIsEditingAddress(false);
} catch (error) { } catch (error) {
@ -237,6 +240,33 @@ export default function ProfilePage() {
})); }));
}; };
const handleChangePassword = async () => {
setIsChangingPassword(true);
setPwdError(null);
setPwdSuccess(null);
try {
if (!pwdForm.currentPassword || !pwdForm.newPassword) {
setPwdError("Please fill in all password fields");
return;
}
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
setPwdError("New password and confirmation do not match");
return;
}
await useAuthStore.getState().changePassword(
pwdForm.currentPassword,
pwdForm.newPassword
);
setPwdSuccess("Password changed successfully.");
setPwdForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
} catch (err) {
setPwdError(err instanceof Error ? err.message : "Failed to change password");
} finally {
setIsChangingPassword(false);
}
};
// Update form data when user data changes (e.g., when Salesforce data loads) // Update form data when user data changes (e.g., when Salesforce data loads)
useEffect(() => { useEffect(() => {
if (user && !isEditing) { if (user && !isEditing) {
@ -660,6 +690,77 @@ export default function ProfilePage() {
)} )}
</div> </div>
</div> </div>
{/* Change Password */}
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Change Password</h2>
</div>
<div className="p-6">
{pwdSuccess && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
{pwdSuccess}
</div>
)}
{pwdError && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{pwdError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input
type="password"
value={pwdForm.currentPassword}
onChange={e => setPwdForm(p => ({ ...p, currentPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
type="password"
value={pwdForm.newPassword}
onChange={e => setPwdForm(p => ({ ...p, newPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="New secure password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
type="password"
value={pwdForm.confirmPassword}
onChange={e => setPwdForm(p => ({ ...p, confirmPassword: e.target.value }))}
className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
placeholder="Re-enter new password"
/>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
<button
onClick={() => {
void handleChangePassword();
}}
disabled={isChangingPassword}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isChangingPassword ? "Changing..." : "Change Password"}
</button>
</div>
<p className="text-xs text-gray-500 mt-3">
Password must be at least 8 characters and include uppercase, lowercase, number, and special character.
</p>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -95,6 +95,12 @@ export default function LoginPage() {
Create one here Create one here
</Link> </Link>
</p> </p>
<p className="text-sm text-gray-600">
Forgot your password?{" "}
<Link href="/auth/forgot-password" className="text-blue-600 hover:text-blue-500">
Reset it
</Link>
</p>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Had an account with us before?{" "} Had an account with us before?{" "}
<Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500"> <Link href="/auth/link-whmcs" className="text-blue-600 hover:text-blue-500">

View File

@ -16,7 +16,7 @@ const schema = z
password: z password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/), .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/),
confirmPassword: z.string(), confirmPassword: z.string(),
}) })
.refine(v => v.password === v.confirmPassword, { .refine(v => v.password === v.confirmPassword, {

View File

@ -17,7 +17,7 @@ const setPasswordSchema = z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
.regex( .regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
"Password must contain uppercase, lowercase, number, and special character" "Password must contain uppercase, lowercase, number, and special character"
), ),
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -29,7 +29,7 @@ const step2Schema = z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
.regex( .regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/,
"Password must contain uppercase, lowercase, number, and special character" "Password must contain uppercase, lowercase, number, and special character"
), ),
confirmPassword: z.string(), confirmPassword: z.string(),
@ -86,7 +86,7 @@ interface SignupData {
export default function SignupPage() { export default function SignupPage() {
const router = useRouter(); const router = useRouter();
const { signup, isLoading } = useAuthStore(); const { signup, isLoading, checkPasswordNeeded } = useAuthStore();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [validationStatus, setValidationStatus] = useState<{ const [validationStatus, setValidationStatus] = useState<{
@ -94,6 +94,12 @@ export default function SignupPage() {
whAccountValid: boolean; whAccountValid: boolean;
sfAccountId?: string; sfAccountId?: string;
} | null>(null); } | null>(null);
const [emailCheckStatus, setEmailCheckStatus] = useState<{
userExists: boolean;
needsPasswordSet: boolean;
showActions: boolean;
} | null>(null);
const emailCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Step 1 Form // Step 1 Form
const step1Form = useForm<Step1Form>({ const step1Form = useForm<Step1Form>({
@ -155,6 +161,35 @@ export default function SignupPage() {
} }
}; };
// Check email when user enters it (debounced)
const handleEmailCheck = useCallback(async (email: string) => {
if (!email || !email.includes("@")) {
setEmailCheckStatus(null);
return;
}
try {
const result = await checkPasswordNeeded(email);
setEmailCheckStatus({
userExists: result.userExists,
needsPasswordSet: result.needsPasswordSet,
showActions: result.userExists,
});
} catch (err) {
// Silently fail email check - don't block the flow
setEmailCheckStatus(null);
}
}, [checkPasswordNeeded]);
const debouncedEmailCheck = useCallback((email: string) => {
if (emailCheckTimeoutRef.current) {
clearTimeout(emailCheckTimeoutRef.current);
}
emailCheckTimeoutRef.current = setTimeout(() => {
void handleEmailCheck(email);
}, 500);
}, [handleEmailCheck]);
// Step 2: Personal Information // Step 2: Personal Information
const onStep2Submit = () => { const onStep2Submit = () => {
setCurrentStep(3); setCurrentStep(3);
@ -332,7 +367,13 @@ export default function SignupPage() {
<div> <div>
<Label htmlFor="email">Email address</Label> <Label htmlFor="email">Email address</Label>
<Input <Input
{...step2Form.register("email")} {...step2Form.register("email", {
onChange: (e) => {
const email = e.target.value;
step2Form.setValue("email", email);
debouncedEmailCheck(email);
}
})}
id="email" id="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
@ -342,6 +383,59 @@ export default function SignupPage() {
{step2Form.formState.errors.email && ( {step2Form.formState.errors.email && (
<p className="mt-1 text-sm text-red-600">{step2Form.formState.errors.email.message}</p> <p className="mt-1 text-sm text-red-600">{step2Form.formState.errors.email.message}</p>
)} )}
{/* Email Check Status */}
{emailCheckStatus?.showActions && (
<div className="mt-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-800">
We found an existing account with this email
</h4>
<div className="mt-2 space-y-2">
{emailCheckStatus.needsPasswordSet ? (
<div>
<p className="text-sm text-blue-700 mb-2">
You need to set a password for your account.
</p>
<Link
href="/auth/set-password"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Set Password
</Link>
</div>
) : (
<div>
<p className="text-sm text-blue-700 mb-2">
Please sign in to your existing account.
</p>
<div className="flex space-x-2">
<Link
href="/auth/login"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Sign In
</Link>
<Link
href="/auth/forgot-password"
className="inline-flex items-center px-3 py-2 border border-blue-300 text-sm leading-4 font-medium rounded-md text-blue-700 bg-white hover:bg-blue-50"
>
Forgot Password?
</Link>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div> </div>
<div> <div>

View File

@ -5,6 +5,8 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { useAuthStore } from "@/lib/auth/store"; import { useAuthStore } from "@/lib/auth/store";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
@ -172,7 +174,7 @@ export default function InvoiceDetailPage() {
</div> </div>
{/* Invoice Card */} {/* Invoice Card */}
<div className="bg-white rounded-lg shadow border"> <div className="bg-white rounded-2xl shadow border">
{/* Invoice Header */} {/* Invoice Header */}
<div className="px-8 py-6 border-b border-gray-200"> <div className="px-8 py-6 border-b border-gray-200">
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
@ -180,7 +182,19 @@ export default function InvoiceDetailPage() {
<div> <div>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<h1 className="text-2xl font-bold text-gray-900">Invoice #{invoice.number}</h1> <h1 className="text-2xl font-bold text-gray-900">Invoice #{invoice.number}</h1>
<InvoiceStatusBadge status={invoice.status} /> {/* Harmonize with StatusPill while keeping existing badge for now */}
<StatusPill
label={invoice.status}
variant={
invoice.status === "Paid"
? "success"
: invoice.status === "Overdue"
? "error"
: invoice.status === "Unpaid"
? "warning"
: "neutral"
}
/>
</div> </div>
<div className="flex flex-col sm:flex-row gap-4 text-sm"> <div className="flex flex-col sm:flex-row gap-4 text-sm">
@ -276,11 +290,10 @@ export default function InvoiceDetailPage() {
</div> </div>
{/* Invoice Body */} {/* Invoice Body */}
<div className="px-8 py-6"> <div className="px-8 py-6 space-y-6">
{/* Items */} {/* Items */}
{invoice.items && invoice.items.length > 0 && ( <SubCard title="Items & Services">
<div className="mb-6"> {invoice.items && invoice.items.length > 0 ? (
<h3 className="text-base font-semibold text-gray-900 mb-3">Items & Services</h3>
<div className="space-y-2"> <div className="space-y-2">
{invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => ( {invoice.items.map((item: import("@customer-portal/shared").InvoiceItem) => (
<InvoiceItemRow <InvoiceItemRow
@ -294,11 +307,13 @@ export default function InvoiceDetailPage() {
/> />
))} ))}
</div> </div>
</div> ) : (
)} <div className="text-sm text-gray-600">No items found on this invoice.</div>
)}
</SubCard>
{/* Total Section */} {/* Totals */}
<div className="border-t border-gray-200 pt-4"> <SubCard title="Totals">
<div className="max-w-xs ml-auto"> <div className="max-w-xs ml-auto">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600"> <div className="flex justify-between text-sm text-gray-600">
@ -321,7 +336,43 @@ export default function InvoiceDetailPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </SubCard>
{/* Actions */}
{(invoice.status === "Unpaid" || invoice.status === "Overdue") && (
<SubCard title="Payment">
<div className="flex flex-wrap gap-2">
<button
onClick={handleManagePaymentMethods}
disabled={loadingPaymentMethods}
className="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors whitespace-nowrap"
>
{loadingPaymentMethods ? (
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-gray-600 mr-1.5"></div>
) : (
<ServerIcon className="h-3 w-3 mr-1.5" />
)}
Payment Methods
</button>
<button
onClick={() => handleCreateSsoLink("pay")}
disabled={loadingPayment}
className={`inline-flex items-center justify-center px-5 py-2.5 border border-transparent text-sm font-semibold rounded-lg text-white transition-all duration-200 shadow-md whitespace-nowrap ${
invoice.status === "Overdue"
? "bg-red-600 hover:bg-red-700 ring-2 ring-red-200 hover:ring-red-300"
: "bg-blue-600 hover:bg-blue-700 hover:shadow-lg"
}`}
>
{loadingPayment ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<ArrowTopRightOnSquareIcon className="h-4 w-4 mr-2" />
)}
{invoice.status === "Overdue" ? "Pay Overdue" : "Pay Now"}
</button>
</div>
</SubCard>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,6 +3,8 @@
import React, { useState, useMemo } from "react"; import React, { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { DataTable } from "@/components/ui/data-table"; import { DataTable } from "@/components/ui/data-table";
import { SearchFilterBar } from "@/components/ui/search-filter-bar"; import { SearchFilterBar } from "@/components/ui/search-filter-bar";
import { import {
@ -65,18 +67,18 @@ export default function InvoicesPage() {
} }
}; };
const getStatusColor = (status: string) => { const getStatusVariant = (status: string) => {
switch (status) { switch (status) {
case "Paid": case "Paid":
return "bg-green-100 text-green-800"; return "success" as const;
case "Unpaid": case "Unpaid":
return "bg-yellow-100 text-yellow-800"; return "warning" as const;
case "Overdue": case "Overdue":
return "bg-red-100 text-red-800"; return "error" as const;
case "Cancelled": case "Cancelled":
return "bg-gray-100 text-gray-800"; return "neutral" as const;
default: default:
return "bg-gray-100 text-gray-800"; return "neutral" as const;
} }
}; };
@ -104,13 +106,7 @@ export default function InvoicesPage() {
{ {
key: "status", key: "status",
header: "Status", header: "Status",
render: (invoice: Invoice) => ( render: (invoice: Invoice) => <StatusPill label={invoice.status} variant={getStatusVariant(invoice.status)} />,
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(invoice.status)}`}
>
{invoice.status}
</span>
),
}, },
{ {
key: "amount", key: "amount",
@ -208,22 +204,74 @@ export default function InvoicesPage() {
title="Invoices" title="Invoices"
description="Manage and view your billing invoices" description="Manage and view your billing invoices"
> >
{/* Search and Filters */} {/* Invoice Table with integrated header filters */}
<SearchFilterBar <SubCard
searchValue={searchTerm} header={
onSearchChange={setSearchTerm} <SearchFilterBar
searchPlaceholder="Search invoices..." searchValue={searchTerm}
filterValue={statusFilter} onSearchChange={setSearchTerm}
onFilterChange={value => { searchPlaceholder="Search invoices..."
setStatusFilter(value); filterValue={statusFilter}
setCurrentPage(1); // Reset to first page when filtering onFilterChange={value => {
}} setStatusFilter(value);
filterOptions={statusFilterOptions} setCurrentPage(1); // Reset to first page when filtering
filterLabel="Filter by status" }}
/> filterOptions={statusFilterOptions}
filterLabel="Filter by status"
{/* Invoice Table */} />
<div className="bg-white shadow rounded-lg overflow-hidden"> }
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
footer={
pagination && filteredInvoices.length > 0 ? (
<div className="px-1 sm:px-0 py-1 flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span>{" "}
to{" "}
<span className="font-medium">{Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}</span>{" "}
of <span className="font-medium">{pagination?.totalItems || 0}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
) : undefined
}
>
<DataTable <DataTable
data={filteredInvoices} data={filteredInvoices}
columns={invoiceColumns} columns={invoiceColumns}
@ -237,64 +285,10 @@ export default function InvoicesPage() {
}} }}
onRowClick={invoice => (window.location.href = `/billing/invoices/${invoice.id}`)} onRowClick={invoice => (window.location.href = `/billing/invoices/${invoice.id}`)}
/> />
</div> </SubCard>
{/* Pagination */} {/* Pagination */}
{pagination && filteredInvoices.length > 0 && ( {/* Pagination moved to SubCard footer above */}
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6 rounded-b-lg">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))}
disabled={currentPage === (pagination?.totalPages || 1)}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(currentPage * itemsPerPage, pagination?.totalItems || 0)}
</span>{" "}
of <span className="font-medium">{pagination?.totalItems || 0}</span> results
</p>
</div>
<div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() =>
setCurrentPage(Math.min(pagination?.totalPages || 1, currentPage + 1))
}
disabled={currentPage === (pagination?.totalPages || 1)}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
</PageLayout> </PageLayout>
); );
} }

View File

@ -1,20 +1,32 @@
"use client"; "use client";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { useState } from "react"; import { useState, useEffect } from "react";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { useAuthStore } from "@/lib/auth/store"; import { useAuthStore } from "@/lib/auth/store";
import { authenticatedApi, ApiError } from "@/lib/api"; import { authenticatedApi, ApiError } from "@/lib/api";
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
import { import {
CreditCardIcon, CreditCardIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/ui/inline-toast";
export default function PaymentMethodsPage() { export default function PaymentMethodsPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { token } = useAuthStore(); const { token } = useAuthStore();
const paymentRefresh = usePaymentRefresh({
// Lightweight refresh: we only care about the count here
refetch: async () => ({
data: await authenticatedApi.get<{ totalCount: number; paymentMethods: unknown[] }>(
"/invoices/payment-methods"
),
}),
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
attachFocusListeners: true,
});
const openPaymentMethods = async () => { const openPaymentMethods = async () => {
try { try {
@ -46,6 +58,12 @@ export default function PaymentMethodsPage() {
} }
}; };
// When returning from WHMCS tab, auto-refresh payment methods and show a short toast
useEffect(() => {
// token gate only (hook already checks focus listeners)
if (!token) return;
}, [token]);
// Show error state // Show error state
if (error) { if (error) {
return ( return (
@ -79,6 +97,7 @@ export default function PaymentMethodsPage() {
title="Payment Methods" title="Payment Methods"
description="Manage your saved payment methods and billing information" description="Manage your saved payment methods and billing information"
> >
<InlineToast visible={paymentRefresh.toast.visible} text={paymentRefresh.toast.text} tone={paymentRefresh.toast.tone} />
{/* Main Content */} {/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Payment Methods Card */} {/* Payment Methods Card */}

View File

@ -3,10 +3,15 @@
import { useState, useEffect, useMemo, useCallback, Suspense } from "react"; import { useState, useEffect, useMemo, useCallback, 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, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ShieldCheckIcon, ExclamationTriangleIcon, CreditCardIcon } 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 { usePaymentMethods } from "@/hooks/useInvoices";
import { usePaymentRefresh } from "@/hooks/usePaymentRefresh";
import { logger } from "@/lib/logger";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { InlineToast } from "@/components/ui/inline-toast";
import { import {
InternetPlan, InternetPlan,
@ -38,6 +43,9 @@ function CheckoutContent() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [addressConfirmed, setAddressConfirmed] = useState(false); const [addressConfirmed, setAddressConfirmed] = useState(false);
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null); const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: "info" | "success" | "warning" }>(
{ visible: false, text: "", tone: "info" }
);
const [checkoutState, setCheckoutState] = useState<CheckoutState>({ const [checkoutState, setCheckoutState] = useState<CheckoutState>({
loading: true, loading: true,
error: null, error: null,
@ -53,6 +61,12 @@ function CheckoutContent() {
refetch: refetchPaymentMethods, refetch: refetchPaymentMethods,
} = usePaymentMethods(); } = usePaymentMethods();
const paymentRefresh = usePaymentRefresh({
refetch: refetchPaymentMethods,
hasMethods: (data?: { totalCount?: number }) => !!data && (data.totalCount || 0) > 0,
attachFocusListeners: true,
});
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
@ -246,11 +260,13 @@ function CheckoutContent() {
}; };
const handleAddressConfirmed = useCallback((address?: Address) => { const handleAddressConfirmed = useCallback((address?: Address) => {
logger.info("Address confirmed in checkout", { address });
setAddressConfirmed(true); setAddressConfirmed(true);
setConfirmedAddress(address || null); setConfirmedAddress(address || null);
}, []); }, []);
const handleAddressIncomplete = useCallback(() => { const handleAddressIncomplete = useCallback(() => {
logger.info("Address marked as incomplete in checkout");
setAddressConfirmed(false); setAddressConfirmed(false);
setConfirmedAddress(null); setConfirmedAddress(null);
}, []); }, []);
@ -276,7 +292,7 @@ function CheckoutContent() {
> >
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-600 mb-4">{checkoutState.error}</p> <p className="text-red-600 mb-4">{checkoutState.error}</p>
<button onClick={() => router.back()} className="text-blue-600 hover:text-blue-800"> <button type="button" onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
Go Back Go Back
</button> </button>
</div> </div>
@ -286,42 +302,131 @@ function CheckoutContent() {
return ( return (
<PageLayout <PageLayout
title="Submit Order" title="Checkout"
description="Submit your order for review and approval" description="Verify your address, review totals, and submit your order"
icon={<ShieldCheckIcon className="h-6 w-6" />} icon={<ShieldCheckIcon className="h-6 w-6" />}
> >
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto space-y-8">
{/* Address Confirmation */} <InlineToast visible={paymentRefresh.toast.visible} text={paymentRefresh.toast.text} tone={paymentRefresh.toast.tone} />
<AddressConfirmation {/* Confirm Details - single card with Address + Payment */}
onAddressConfirmed={handleAddressConfirmed} <div className="bg-gray-50 border border-gray-200 rounded-2xl p-6 md:p-7 shadow-sm">
onAddressIncomplete={handleAddressIncomplete} <div className="flex items-center gap-3 mb-6">
orderType={orderType} <ShieldCheckIcon className="w-6 h-6 text-blue-600" />
/> <h2 className="text-lg font-semibold text-gray-900">Confirm Details</h2>
</div>
<div className="space-y-5">
{/* Sub-card: Installation Address */}
<SubCard>
<AddressConfirmation
embedded
onAddressConfirmed={handleAddressConfirmed}
onAddressIncomplete={handleAddressIncomplete}
orderType={orderType}
/>
</SubCard>
{/* Order Submission Message */} {/* Sub-card: Billing & Payment */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6 mb-6 text-center"> <SubCard
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4"> title="Billing & Payment"
icon={<CreditCardIcon className="w-5 h-5 text-blue-600" />}
right={
paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<StatusPill label="Verified" variant="success" />
) : undefined
}
>
{paymentMethodsLoading ? (
<div className="flex items-center gap-3 text-sm text-gray-600">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
Checking payment methods...
</div>
) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
<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">If you just added a payment method, try refreshing.</p>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => {
void paymentRefresh.triggerRefresh();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Check Again
</button>
<button
type="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 ? (
<p className="text-sm text-green-700">Payment will be processed using your card on file after approval.</p>
) : (
<div className="bg-red-50 border border-red-200 rounded-md p-3">
<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">Add a payment method to submit your order.</p>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => {
void paymentRefresh.triggerRefresh();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Check Again
</button>
<button
type="button"
onClick={() => router.push("/billing/payments")}
className="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>
)}
</SubCard>
</div>
</div>
{/* Review & Submit - prominent card with guidance */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-2xl p-6 md:p-7 text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4 shadow-sm">
<ShieldCheckIcon className="w-8 h-8 text-blue-600" /> <ShieldCheckIcon className="w-8 h-8 text-blue-600" />
</div> </div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2> <h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
<p className="text-gray-600 mb-4"> <p className="text-gray-700 mb-4 max-w-xl mx-auto">
You&apos;ve configured your service and reviewed all details. Your order will be Youre almost done. Confirm your details above, then submit your order. Well review and notify you when everything is ready.
submitted for review and approval.
</p> </p>
<div className="bg-white rounded-lg p-4 border border-blue-200"> <div className="bg-white rounded-lg p-4 border border-blue-200 text-left max-w-2xl mx-auto">
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3> <h3 className="font-semibold text-gray-900 mb-2">What to expect</h3>
<div className="text-sm text-gray-600 space-y-1"> <div className="text-sm text-gray-700 space-y-1">
<p> Your order will be reviewed by our team</p> <p> Our team reviews your order and schedules setup if needed</p>
<p> We&apos;ll set up your services in our system</p> <p> We may contact you to confirm details or availability</p>
<p> Payment will be processed using your card on file</p> <p> We only charge your card after the order is approved</p>
<p> You&apos;ll receive confirmation once everything is ready</p> <p> Youll receive confirmation and next steps by email</p>
</div> </div>
</div> </div>
{/* Quick Totals Summary */} {/* Totals Summary */}
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200"> <div className="mt-4 bg-white rounded-lg p-4 border border-gray-200 max-w-2xl mx-auto">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-medium text-gray-700">Total:</span> <span className="font-medium text-gray-700">Estimated Total</span>
<div className="text-right"> <div className="text-right">
<div className="text-xl font-bold text-gray-900"> <div className="text-xl font-bold text-gray-900">
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo ¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
@ -336,130 +441,10 @@ function CheckoutContent() {
</div> </div>
</div> </div>
<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>
{paymentMethodsLoading ? (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span className="text-gray-600 text-sm">Checking payment methods...</span>
</div>
</div>
) : paymentMethodsError ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-amber-800 text-sm font-medium">
Unable to verify payment methods
</p>
<p className="text-amber-700 text-sm mt-1">
We couldn&apos;t check your payment methods. If you just added a payment method,
try refreshing.
</p>
<div className="flex gap-2 mt-2">
<button
onClick={() => {
void (async () => {
try {
await authenticatedApi.post("/invoices/payment-methods/refresh");
} catch (error) {
console.warn(
"Backend cache refresh failed, using frontend refresh:",
error
);
}
try {
await refetchPaymentMethods();
} catch (error) {
console.error("Frontend refresh also failed:", error);
}
})();
}}
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
>
Refresh Cache
</button>
<button
onClick={() => router.push("/billing/payments")}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
</div>
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
<p className="text-green-700 text-sm mt-1">
After order approval, payment will be automatically processed using your
existing payment method on file. No additional payment steps required.
</p>
</div>
</div>
</div>
) : (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
<p className="text-red-700 text-sm mt-1">
You need to add a payment method before submitting your order. Please add a
credit card or other payment method to proceed.
</p>
<button
onClick={() => router.push("/billing/payments")}
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
>
Add Payment Method
</button>
</div>
</div>
</div>
)}
</div>
{/* Debug Info - Remove in production */}
<div className="bg-gray-100 border rounded-lg p-3 mb-4 text-xs text-gray-600">
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? "✅" : "❌"} (
{String(addressConfirmed)}) | Payment Methods:{" "}
{paymentMethodsLoading
? "⏳ Loading..."
: paymentMethodsError
? "❌ Error"
: paymentMethods
? `${paymentMethods.paymentMethods.length} found`
: "❌ None"}{" "}
| Order Items: {checkoutState.orderItems.length} | Can Submit:{" "}
{!(
submitting ||
checkoutState.orderItems.length === 0 ||
!addressConfirmed ||
paymentMethodsLoading ||
!paymentMethods ||
paymentMethods.paymentMethods.length === 0
)
? "✅"
: "❌"}{" "}
| Render Time: {new Date().toLocaleTimeString()}
</div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
type="button"
onClick={() => { onClick={() => {
// Construct the configure URL with current parameters to preserve data // Construct the configure URL with current parameters to preserve data
// Add step parameter to go directly to review step // Add step parameter to go directly to review step
@ -479,6 +464,7 @@ function CheckoutContent() {
</button> </button>
<button <button
type="button"
onClick={() => void handleSubmitOrder()} onClick={() => void handleSubmitOrder()}
disabled={ disabled={
submitting || submitting ||
@ -515,13 +501,13 @@ function CheckoutContent() {
Submitting Order... Submitting Order...
</span> </span>
) : !addressConfirmed ? ( ) : !addressConfirmed ? (
"📍 Complete Address to Continue" "📍 Confirm Installation Address"
) : paymentMethodsLoading ? ( ) : paymentMethodsLoading ? (
"⏳ Verifying Payment Method..." "⏳ Verifying Payment Method..."
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? ( ) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
"💳 Add Payment Method to Continue" "💳 Add Payment Method to Continue"
) : ( ) : (
"📋 Submit Order for Review" "📋 Submit Order"
)} )}
</button> </button>
</div> </div>

View File

@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "../styles/tokens.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@ -4,6 +4,8 @@ import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentCheckIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { ClipboardDocumentCheckIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { SubCard } from "@/components/ui/sub-card";
import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
interface OrderItem { interface OrderItem {
@ -218,6 +220,15 @@ export default function OrderStatusPage() {
); );
const serviceIcon = getServiceTypeIcon(data.orderType); const serviceIcon = getServiceTypeIcon(data.orderType);
const statusVariant =
statusInfo.label.includes("Active")
? "success"
: statusInfo.label.includes("Review") ||
statusInfo.label.includes("Setting Up") ||
statusInfo.label.includes("Scheduled")
? "info"
: "neutral";
return ( return (
<div className="bg-white border rounded-2xl p-8 mb-8"> <div className="bg-white border rounded-2xl p-8 mb-8">
{/* Service Header */} {/* Service Header */}
@ -281,70 +292,31 @@ export default function OrderStatusPage() {
})()} })()}
</div> </div>
{/* Status Card */} {/* Status Card (standardized) */}
<div className={`border-2 rounded-xl p-6 ${statusInfo.bgColor}`}> <SubCard
<div className="flex items-start gap-4"> title="Status"
<div right={<StatusPill label={statusInfo.label} variant={statusVariant as any} />}
className={`w-12 h-12 rounded-full ${statusInfo.bgColor.replace("bg-", "bg-").replace("-50", "-100")} flex items-center justify-center`} >
> <div className="text-gray-700 mb-2">{statusInfo.description}</div>
{data.status === "Activated" ? ( {statusInfo.nextAction && (
<svg <div className="bg-gray-50 rounded-lg p-3 mb-3 border border-gray-200">
className="w-6 h-6 text-green-600" <p className="font-medium text-gray-900 text-sm">Next Steps</p>
fill="currentColor" <p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className={`w-6 h-6 ${statusInfo.color}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</div> </div>
)}
<div className="flex-1"> {statusInfo.timeline && (
<h3 className={`text-xl font-bold ${statusInfo.color} mb-2`}> <p className="text-sm text-gray-600">
{statusInfo.label} <span className="font-medium">Timeline:</span> {statusInfo.timeline}
</h3> </p>
<p className="text-gray-700 mb-2">{statusInfo.description}</p> )}
</SubCard>
{statusInfo.nextAction && (
<div className="bg-white bg-opacity-60 rounded-lg p-3 mb-3">
<p className="font-medium text-gray-900 text-sm">Next Steps:</p>
<p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
</div>
)}
{statusInfo.timeline && (
<p className="text-sm text-gray-600">
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
</p>
)}
</div>
</div>
</div>
</div> </div>
); );
})()} })()}
{/* Service Details */} {/* Service Details */}
{data?.items && data.items.length > 0 && ( {data?.items && data.items.length > 0 && (
<div className="bg-white border rounded-xl p-6 mb-6"> <SubCard title="Your Services & Products">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Your Services & Products</h2>
<div className="space-y-3"> <div className="space-y-3">
{data.items.map(item => { {data.items.map(item => {
// Use the actual Item_Class__c values from Salesforce documentation // Use the actual Item_Class__c values from Salesforce documentation
@ -447,7 +419,7 @@ export default function OrderStatusPage() {
); );
})} })}
</div> </div>
</div> </SubCard>
)} )}
{/* Pricing Summary */} {/* Pricing Summary */}
@ -457,9 +429,7 @@ export default function OrderStatusPage() {
const totals = calculateDetailedTotals(data.items); const totals = calculateDetailedTotals(data.items);
return ( return (
<div className="bg-white border rounded-xl p-6 mb-6"> <SubCard title="Pricing Summary">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Pricing Summary</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{totals.monthlyTotal > 0 && ( {totals.monthlyTotal > 0 && (
<div className="bg-blue-50 rounded-lg p-4 text-center"> <div className="bg-blue-50 rounded-lg p-4 text-center">
@ -494,15 +464,14 @@ export default function OrderStatusPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </SubCard>
); );
})()} })()}
{/* Support Contact */} {/* Support Contact */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4"> <SubCard title="Need Help?">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="font-semibold text-blue-900">Need Help?</h3> <p className="text-gray-700 text-sm">
<p className="text-blue-800 text-sm">
Questions about your order? Contact our support team. Questions about your order? Contact our support team.
</p> </p>
</div> </div>
@ -521,7 +490,7 @@ export default function OrderStatusPage() {
</a> </a>
</div> </div>
</div> </div>
</div> </SubCard>
</PageLayout> </PageLayout>
); );
} }

View File

@ -4,6 +4,7 @@ import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/ui/status-pill";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
interface OrderSummary { interface OrderSummary {
@ -218,11 +219,16 @@ export default function OrdersPage() {
</div> </div>
<div className="text-right"> <div className="text-right">
<span <StatusPill
className={`px-4 py-2 rounded-full text-sm font-semibold ${statusInfo.bgColor} ${statusInfo.color}`} label={statusInfo.label}
> variant={
{statusInfo.label} statusInfo.label === "Active"
</span> ? "success"
: statusInfo.label === "Setting Up" || statusInfo.label === "Under Review"
? "info"
: "neutral"
}
/>
</div> </div>
</div> </div>

View File

@ -4,6 +4,8 @@ import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout"; import { PageLayout } from "@/components/layout/page-layout";
import { DataTable } from "@/components/ui/data-table"; import { DataTable } from "@/components/ui/data-table";
import { StatusPill } from "@/components/ui/status-pill";
import { SubCard } from "@/components/ui/sub-card";
import { SearchFilterBar } from "@/components/ui/search-filter-bar"; import { SearchFilterBar } from "@/components/ui/search-filter-bar";
import { import {
ServerIcon, ServerIcon,
@ -14,11 +16,12 @@ import {
CalendarIcon, CalendarIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// (duplicate SubCard import removed)
import { format } from "date-fns"; import { format } from "date-fns";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks"; import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency"; import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
import type { Subscription } from "@customer-portal/shared"; import type { Subscription } from "@customer-portal/shared";
import { SubscriptionStatusBadge } from "@/features/subscriptions/components"; // Removed unused SubscriptionStatusBadge in favor of StatusPill
export default function SubscriptionsPage() { export default function SubscriptionsPage() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@ -85,6 +88,22 @@ export default function SubscriptionsPage() {
{ value: "Terminated", label: "Terminated" }, { value: "Terminated", label: "Terminated" },
]; ];
const getStatusVariant = (status: string) => {
switch (status) {
case "Active":
return "success" as const;
case "Suspended":
return "warning" as const;
case "Pending":
return "info" as const;
case "Cancelled":
case "Terminated":
return "neutral" as const;
default:
return "neutral" as const;
}
};
const subscriptionColumns = [ const subscriptionColumns = [
{ {
key: "service", key: "service",
@ -103,7 +122,7 @@ export default function SubscriptionsPage() {
key: "status", key: "status",
header: "Status", header: "Status",
render: (subscription: Subscription) => ( render: (subscription: Subscription) => (
<SubscriptionStatusBadge status={subscription.status} /> <StatusPill label={subscription.status} variant={getStatusVariant(subscription.status)} />
), ),
}, },
{ {
@ -146,7 +165,7 @@ export default function SubscriptionsPage() {
key: "nextDue", key: "nextDue",
header: "Next Due", header: "Next Due",
render: (subscription: Subscription) => ( render: (subscription: Subscription) => (
<div className="flex items.center"> <div className="flex items-center">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" /> <CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
{subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"} {subscription.nextDue ? formatDate(subscription.nextDue) : "N/A"}
@ -218,97 +237,92 @@ export default function SubscriptionsPage() {
icon={<ServerIcon />} icon={<ServerIcon />}
title="Subscriptions" title="Subscriptions"
description="Manage your active services and subscriptions" description="Manage your active services and subscriptions"
> actions={
<div className="mb-4 flex justify-end">
<Link <Link
href="/catalog" href="/catalog"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md 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" className="inline-flex items-center px-4 py-2 border border-transparent rounded-md 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 whitespace-nowrap"
> >
Order Services Order Services
</Link> </Link>
</div> }
>
{/* Stats Cards */}
{/* Stats Cards */} {/* Stats Cards */}
{stats && ( {stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white overflow-hidden shadow rounded-lg"> <SubCard>
<div className="p-5"> <div className="flex items-center">
<div className="flex items-center"> <div className="flex-shrink-0">
<div className="flex-shrink-0"> <CheckCircleIcon className="h-8 w-8 text-green-600" />
<CheckCircleIcon className="h-8 w-8 text-green-600" /> </div>
</div> <div className="ml-5 w-0 flex-1">
<div className="ml-5 w-0 flex-1"> <dl>
<dl> <dt className="text-sm font-medium text-gray-500 truncate">Active</dt>
<dt className="text-sm font-medium text-gray-500 truncate">Active</dt> <dd className="text-lg font-medium text-gray-900">{stats.active}</dd>
<dd className="text-lg font-medium text-gray-900">{stats.active}</dd> </dl>
</dl>
</div>
</div> </div>
</div> </div>
</div> </SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg"> <SubCard>
<div className="p-5"> <div className="flex items-center">
<div className="flex items-center"> <div className="flex-shrink-0">
<div className="flex-shrink-0"> <ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" />
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" /> </div>
</div> <div className="ml-5 w-0 flex-1">
<div className="ml-5 w-0 flex-1"> <dl>
<dl> <dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt>
<dt className="text-sm font-medium text.gray-500 truncate">Suspended</dt> <dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd>
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd> </dl>
</dl>
</div>
</div> </div>
</div> </div>
</div> </SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg"> <SubCard>
<div className="p-5"> <div className="flex items-center">
<div className="flex items.center"> <div className="flex-shrink-0">
<div className="flex-shrink-0"> <ClockIcon className="h-8 w-8 text-blue-600" />
<ClockIcon className="h-8 w-8 text-blue-600" /> </div>
</div> <div className="ml-5 w-0 flex-1">
<div className="ml-5 w-0 flex-1"> <dl>
<dl> <dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt> <dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd> </dl>
</dl>
</div>
</div> </div>
</div> </div>
</div> </SubCard>
<div className="bg-white overflow-hidden shadow rounded-lg"> <SubCard>
<div className="p-5"> <div className="flex items-center">
<div className="flex items-center"> <div className="flex-shrink-0">
<div className="flex-shrink-0"> <XCircleIcon className="h-8 w-8 text-gray-600" />
<XCircleIcon className="h-8 w-8 text-gray-600" /> </div>
</div> <div className="ml-5 w-0 flex-1">
<div className="ml-5 w-0 flex-1"> <dl>
<dl> <dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt>
<dt className="text-sm font-medium text-gray-500 truncate">Cancelled</dt> <dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd>
<dd className="text-lg font-medium text-gray-900">{stats.cancelled}</dd> </dl>
</dl>
</div>
</div> </div>
</div> </div>
</div> </SubCard>
</div> </div>
)} )}
{/* Search and Filters */} {/* Subscriptions Table with integrated header + CTA */}
<SearchFilterBar <SubCard
searchValue={searchTerm} header={
onSearchChange={setSearchTerm} <SearchFilterBar
searchPlaceholder="Search subscriptions..." searchValue={searchTerm}
filterValue={statusFilter} onSearchChange={setSearchTerm}
onFilterChange={setStatusFilter} searchPlaceholder="Search subscriptions..."
filterOptions={statusFilterOptions} filterValue={statusFilter}
filterLabel="Filter by status" onFilterChange={setStatusFilter}
/> filterOptions={statusFilterOptions}
filterLabel="Filter by status"
{/* Subscriptions Table */} />
<div className="bg-white shadow rounded-lg overflow-hidden"> }
headerClassName="bg-gray-50 rounded md:p-2 p-1 mb-1"
>
<DataTable <DataTable
data={filteredSubscriptions} data={filteredSubscriptions}
columns={subscriptionColumns} columns={subscriptionColumns}
@ -322,7 +336,7 @@ export default function SubscriptionsPage() {
}} }}
onRowClick={subscription => (window.location.href = `/subscriptions/${subscription.id}`)} onRowClick={subscription => (window.location.href = `/subscriptions/${subscription.id}`)}
/> />
</div> </SubCard>
</PageLayout> </PageLayout>
); );
} }

View File

@ -2,6 +2,8 @@
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
import { logger } from "@/lib/logger";
import { StatusPill } from "@/components/ui/status-pill";
import { import {
MapPinIcon, MapPinIcon,
PencilIcon, PencilIcon,
@ -31,12 +33,14 @@ interface AddressConfirmationProps {
onAddressConfirmed: (address?: Address) => void; onAddressConfirmed: (address?: Address) => void;
onAddressIncomplete: () => void; onAddressIncomplete: () => void;
orderType?: string; // Add order type to customize behavior orderType?: string; // Add order type to customize behavior
embedded?: boolean; // When true, render without outer card wrapper
} }
export function AddressConfirmation({ export function AddressConfirmation({
onAddressConfirmed, onAddressConfirmed,
onAddressIncomplete, onAddressIncomplete,
orderType, orderType,
embedded = false,
}: AddressConfirmationProps) { }: AddressConfirmationProps) {
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null); const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -73,10 +77,14 @@ export function AddressConfirmation({
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]); }, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]);
useEffect(() => { useEffect(() => {
logger.info("Address confirmation component mounted");
void fetchBillingInfo(); void fetchBillingInfo();
}, [fetchBillingInfo]); }, [fetchBillingInfo]);
const handleEdit = () => { const handleEdit = (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
setEditing(true); setEditing(true);
setEditedAddress( setEditedAddress(
billingInfo?.address || { billingInfo?.address || {
@ -90,7 +98,10 @@ export function AddressConfirmation({
); );
}; };
const handleSave = () => { const handleSave = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!editedAddress) return; if (!editedAddress) return;
// Validate required fields // Validate required fields
@ -107,59 +118,91 @@ export function AddressConfirmation({
return; return;
} }
try { (async () => {
setError(null); try {
// Use the edited address for the order (will be flagged as changed) setError(null);
onAddressConfirmed(editedAddress);
setEditing(false);
setAddressConfirmed(true);
// Update local state to show the new address // Build minimal PATCH payload with only provided fields
if (billingInfo) { const payload: Record<string, string> = {};
setBillingInfo({ if (editedAddress.street) payload.street = editedAddress.street;
...billingInfo, if (editedAddress.streetLine2) payload.streetLine2 = editedAddress.streetLine2;
address: editedAddress, if (editedAddress.city) payload.city = editedAddress.city;
isComplete: true, if (editedAddress.state) payload.state = editedAddress.state;
}); if (editedAddress.postalCode) payload.postalCode = editedAddress.postalCode;
if (editedAddress.country) payload.country = editedAddress.country;
// Persist to server (WHMCS via BFF)
const updated = await authenticatedApi.patch<BillingInfo>("/me/address", payload);
// Update local state using authoritative response
setBillingInfo(updated);
// Use the edited address for the order (will be flagged as changed)
onAddressConfirmed(updated.address);
setEditing(false);
setAddressConfirmed(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update address");
} }
} catch (err) { })();
setError(err instanceof Error ? err.message : "Failed to update address");
}
}; };
const handleConfirmAddress = () => { const handleConfirmAddress = (e: React.MouseEvent<HTMLButtonElement>) => {
// Defensively prevent any parent form/link behaviors from triggering navigation
if (e && typeof e.preventDefault === "function") e.preventDefault();
if (e && typeof e.stopPropagation === "function") e.stopPropagation();
logger.info("Confirm installation address clicked");
// Ensure we have an address before confirming
if (billingInfo?.address) { if (billingInfo?.address) {
logger.info("Address confirmed", { address: billingInfo.address });
onAddressConfirmed(billingInfo.address); onAddressConfirmed(billingInfo.address);
setAddressConfirmed(true); setAddressConfirmed(true);
} else {
logger.warn("No billing info or address available");
setAddressConfirmed(false);
} }
}; };
const handleCancel = () => { const handleCancel = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditing(false); setEditing(false);
setEditedAddress(null); setEditedAddress(null);
setError(null); setError(null);
}; };
const statusVariant = addressConfirmed || !requiresAddressVerification ? "success" : "warning";
// Note: Avoid defining wrapper components inside render to prevent remounts (focus loss)
const wrap = (node: React.ReactNode) =>
embedded ? (
<>{node}</>
) : (
<div className="bg-white border rounded-xl p-6 mb-6">{node}</div>
);
if (loading) { if (loading) {
return ( return wrap(
<div className="bg-white border rounded-xl p-6 mb-6"> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-3"> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div> <span className="text-gray-600">Loading address information...</span>
<span className="text-gray-600">Loading address information...</span>
</div>
</div> </div>
); );
} }
if (error) { if (error) {
return ( return wrap(
<div className="bg-red-50 border border-red-200 rounded-xl p-6 mb-6"> <div className="bg-red-50 border border-red-200 rounded-xl p-6">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" /> <ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div> <div>
<h3 className="text-sm font-medium text-red-800">Address Error</h3> <h3 className="text-sm font-medium text-red-800">Address Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p> <p className="text-sm text-red-700 mt-1">{error}</p>
<button <button
type="button"
onClick={() => void fetchBillingInfo()} onClick={() => void fetchBillingInfo()}
className="text-sm text-red-600 hover:text-red-500 font-medium mt-2" className="text-sm text-red-600 hover:text-red-500 font-medium mt-2"
> >
@ -173,48 +216,31 @@ export function AddressConfirmation({
if (!billingInfo) return null; if (!billingInfo) return null;
return ( return wrap(
<div className="bg-white border rounded-xl p-6 mb-6"> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-600" /> <MapPinIcon className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900"> <div>
{isInternetOrder <h3 className="text-lg font-semibold text-gray-900">
? "Verify Installation Address" {isInternetOrder
: billingInfo.isComplete ? "Installation Address"
? "Confirm Service Address" : billingInfo.isComplete
: "Complete Your Address"} ? "Service Address"
</h3> : "Complete Your Address"}
</div> </h3>
{billingInfo.isComplete && !editing && (
<button
onClick={handleEdit}
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
>
<PencilIcon className="h-4 w-4" />
<span>Edit</span>
</button>
)}
</div>
{/* Address should always be complete since it's required at signup */}
{isInternetOrder && !addressConfirmed && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-start space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-blue-800">
<strong>Internet Installation Address Verification Required</strong>
</p>
<p className="text-sm text-blue-700 mt-1">
Please verify this is the correct address for your internet installation. A
technician will visit this location for setup.
</p>
</div>
</div> </div>
</div> </div>
)} <div className="flex items-center gap-2">
{/* Consistent status pill placement (right side) */}
<StatusPill
label={statusVariant === "success" ? "Verified" : "Pending confirmation"}
variant={statusVariant}
/>
</div>
</div>
{/* Consolidated single card without separate banner */}
{editing ? ( {editing ? (
<div className="space-y-4"> <div className="space-y-4">
@ -316,6 +342,7 @@ export function AddressConfirmation({
<div className="flex items-center space-x-3 pt-4"> <div className="flex items-center space-x-3 pt-4">
<button <button
type="button"
onClick={handleSave} onClick={handleSave}
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors" className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
> >
@ -323,6 +350,7 @@ export function AddressConfirmation({
<span>Save Address</span> <span>Save Address</span>
</button> </button>
<button <button
type="button"
onClick={handleCancel} onClick={handleCancel}
className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors" className="flex items-center space-x-2 bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
> >
@ -334,56 +362,78 @@ export function AddressConfirmation({
) : ( ) : (
<div> <div>
{billingInfo.address.street ? ( {billingInfo.address.street ? (
<div className="bg-gray-50 rounded-lg p-4"> <div className="space-y-4">
<div className="text-gray-900"> <div className="text-gray-900 space-y-1">
<p className="font-medium">{billingInfo.address.street}</p> <p className="font-semibold text-base">{billingInfo.address.street}</p>
{billingInfo.address.streetLine2 && <p>{billingInfo.address.streetLine2}</p>} {billingInfo.address.streetLine2 && (
<p> <p className="text-gray-700">{billingInfo.address.streetLine2}</p>
{billingInfo.address.city}, {billingInfo.address.state}{" "} )}
{billingInfo.address.postalCode} <p className="text-gray-700">
{billingInfo.address.city}, {billingInfo.address.state} {billingInfo.address.postalCode}
</p> </p>
<p>{billingInfo.address.country}</p> <p className="text-gray-600">{billingInfo.address.country}</p>
</div> </div>
{/* Address Confirmation for Internet Orders */} {/* Status message for Internet orders when pending */}
{isInternetOrder && !addressConfirmed && ( {isInternetOrder && !addressConfirmed && (
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center justify-between"> <div className="flex items-start gap-3">
<div className="flex items-center space-x-2"> <ExclamationTriangleIcon className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" /> <div>
<span className="text-sm text-amber-700 font-medium"> <p className="text-sm font-medium text-amber-800">Verification Required</p>
Verification Required <p className="text-sm text-amber-700 mt-1">
</span> Please confirm this is the correct installation address for your internet service.
</p>
</div> </div>
<button
onClick={handleConfirmAddress}
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors font-medium"
>
Confirm Installation Address
</button>
</div> </div>
</div> </div>
)} )}
{/* Address Confirmed Status */} {/* Action buttons */}
{addressConfirmed && ( <div className="flex items-center justify-between pt-4 border-t border-gray-100">
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="flex items-center gap-3">
<div className="flex items-center space-x-2"> {/* Primary action when pending for Internet orders */}
<CheckIcon className="h-4 w-4 text-green-500" /> {isInternetOrder && !addressConfirmed && !editing && (
<span className="text-sm text-green-700 font-medium"> <button
{isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"} type="button"
</span> onClick={handleConfirmAddress}
</div> onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
}
}}
className="bg-blue-600 text-white px-6 py-2.5 rounded-lg hover:bg-blue-700 transition-colors font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Confirm Address
</button>
)}
</div> </div>
)}
{/* Edit button - always on the right */}
{billingInfo.isComplete && !editing && (
<button
type="button"
onClick={handleEdit}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg border border-blue-200 hover:border-blue-300 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 shadow-sm hover:shadow-md"
>
<PencilIcon className="h-4 w-4" />
Edit Address
</button>
)}
</div>
</div> </div>
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" /> <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<p className="text-gray-600 mb-4">No address on file</p> <MapPinIcon className="h-8 w-8 text-gray-400" />
</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">No Address on File</h4>
<p className="text-gray-600 mb-6">Please add your installation address to continue.</p>
<button <button
type="button"
onClick={handleEdit} onClick={handleEdit}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors" className="bg-blue-600 text-white px-6 py-2.5 rounded-lg hover:bg-blue-700 transition-colors font-medium shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
> >
Add Address Add Address
</button> </button>
@ -391,6 +441,6 @@ export function AddressConfirmation({
)} )}
</div> </div>
)} )}
</div> </>
); );
} }

View File

@ -110,7 +110,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
} }
return ( return (
<div className="h-screen flex overflow-hidden bg-gray-100"> <div className="h-screen flex overflow-hidden bg-gray-50">
{/* Mobile sidebar overlay */} {/* Mobile sidebar overlay */}
{sidebarOpen && ( {sidebarOpen && (
<div className="fixed inset-0 flex z-40 md:hidden"> <div className="fixed inset-0 flex z-40 md:hidden">

View File

@ -5,22 +5,26 @@ interface PageLayoutProps {
icon: ReactNode; icon: ReactNode;
title: string; title: string;
description: string; description: string;
actions?: ReactNode; // optional right-aligned header actions (e.g., CTA button)
children: ReactNode; children: ReactNode;
} }
export function PageLayout({ icon, title, description, children }: PageLayoutProps) { export function PageLayout({ icon, title, description, actions, children }: PageLayoutProps) {
return ( return (
<DashboardLayout> <DashboardLayout>
<div className="py-6"> <div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center"> <div className="flex items-start justify-between gap-4">
<div className="h-8 w-8 text-blue-600 mr-3">{icon}</div> <div className="flex items-center">
<div> <div className="h-8 w-8 text-blue-600 mr-3">{icon}</div>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1> <div>
<p className="text-gray-600">{description}</p> <h1 className="text-2xl font-bold text-gray-900">{title}</h1>
<p className="text-gray-600">{description}</p>
</div>
</div> </div>
{actions ? <div className="flex-shrink-0">{actions}</div> : null}
</div> </div>
</div> </div>

View File

@ -0,0 +1,34 @@
import type { HTMLAttributes } from "react";
type Tone = "info" | "success" | "warning" | "error";
interface InlineToastProps extends HTMLAttributes<HTMLDivElement> {
visible: boolean;
text: string;
tone?: Tone;
}
export function InlineToast({ visible, text, tone = "info", className = "", ...rest }: InlineToastProps) {
const toneClasses =
tone === "success"
? "bg-green-50 border-green-200 text-green-800"
: tone === "warning"
? "bg-amber-50 border-amber-200 text-amber-800"
: tone === "error"
? "bg-red-50 border-red-200 text-red-800"
: "bg-blue-50 border-blue-200 text-blue-800";
return (
<div
className={`fixed bottom-6 right-6 z-50 transition-all duration-200 ${
visible ? "opacity-100 translate-y-0" : "opacity-0 pointer-events-none translate-y-2"
} ${className}`}
{...rest}
>
<div className={`flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg min-w-[220px] text-sm ${toneClasses}`}>
<span>{text}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import type { HTMLAttributes } from "react";
type Variant = "success" | "warning" | "info" | "neutral" | "error";
interface StatusPillProps extends HTMLAttributes<HTMLSpanElement> {
label: string;
variant?: Variant;
}
export function StatusPill({ label, variant = "neutral", className = "", ...rest }: StatusPillProps) {
const tone =
variant === "success"
? "bg-green-50 text-green-700 ring-green-600/20"
: variant === "warning"
? "bg-amber-50 text-amber-700 ring-amber-600/20"
: variant === "info"
? "bg-blue-50 text-blue-700 ring-blue-600/20"
: variant === "error"
? "bg-red-50 text-red-700 ring-red-600/20"
: "bg-gray-50 text-gray-700 ring-gray-400/30";
return (
<span
className={`inline-flex items-center rounded-[var(--cp-pill-radius)] px-[var(--cp-pill-px)] py-[var(--cp-pill-py)] text-xs font-medium ring-1 ring-inset ${tone} ${className}`}
{...rest}
>
{label}
</span>
);
}

View File

@ -0,0 +1,47 @@
import type { ReactNode } from "react";
interface SubCardProps {
title?: string;
icon?: ReactNode;
right?: ReactNode;
header?: ReactNode; // Optional custom header content (overrides title/icon/right layout)
footer?: ReactNode; // Optional footer section separated by a subtle divider
children: ReactNode;
className?: string;
headerClassName?: string;
bodyClassName?: string;
}
export function SubCard({
title,
icon,
right,
header,
footer,
children,
className = "",
headerClassName = "",
bodyClassName = "",
}: SubCardProps) {
return (
<div
className={`border border-gray-200 bg-white shadow-[var(--cp-card-shadow)] rounded-[var(--cp-card-radius)] p-[var(--cp-card-padding)] ${className}`}
>
{header ? (
<div className={`${headerClassName || "mb-3"}`}>{header}</div>
) : title ? (
<div className={`flex items-center justify-between mb-3 ${headerClassName}`}>
<div className="flex items-center gap-2">
{icon}
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
</div>
{right}
</div>
) : null}
<div className={bodyClassName}>{children}</div>
{footer ? (
<div className="mt-3 pt-3 border-t border-gray-100">{footer}</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { authenticatedApi } from "@/lib/api";
type Tone = "info" | "success" | "warning" | "error";
interface UsePaymentRefreshOptions<T> {
// Refetch function from usePaymentMethods
refetch: () => Promise<{ data: T | undefined }>;
// Given refetch result, determine if user has payment methods
hasMethods: (data: T | undefined) => boolean;
// When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean;
}
export function usePaymentRefresh<T>({ refetch, hasMethods, attachFocusListeners = false }: UsePaymentRefreshOptions<T>) {
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false,
text: "",
tone: "info",
});
const triggerRefresh = async () => {
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
try {
try {
await authenticatedApi.post("/invoices/payment-methods/refresh");
} catch (err) {
// Soft-fail cache refresh, still attempt refetch
// eslint-disable-next-line no-console
console.warn("Payment methods cache refresh failed:", err);
}
const result = await refetch();
const has = hasMethods(result.data);
setToast({
visible: true,
text: has ? "Payment methods updated" : "No payment method found yet",
tone: has ? "success" : "warning",
});
} catch (_e) {
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
} finally {
setTimeout(() => setToast(t => ({ ...t, visible: false })), 2200);
}
};
useEffect(() => {
if (!attachFocusListeners) return;
const onFocus = () => {
void triggerRefresh();
};
const onVis = () => {
if (document.visibilityState === "visible") void triggerRefresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
}, [attachFocusListeners]);
return { toast, triggerRefresh, setToast } as const;
}

View File

@ -45,6 +45,21 @@ export interface ResetPasswordData {
password: string; password: string;
} }
export interface ChangePasswordData {
currentPassword: string;
newPassword: string;
}
export interface CheckPasswordNeededData {
email: string;
}
export interface CheckPasswordNeededResponse {
needsPasswordSet: boolean;
userExists: boolean;
email?: string;
}
export interface AuthResponse { export interface AuthResponse {
user: { user: {
id: string; id: string;
@ -154,6 +169,26 @@ class AuthAPI {
}, },
}); });
} }
async changePassword(
token: string,
data: ChangePasswordData
): Promise<AuthResponse> {
return this.request<AuthResponse>("/auth/change-password", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
}
async checkPasswordNeeded(data: CheckPasswordNeededData): Promise<CheckPasswordNeededResponse> {
return this.request<CheckPasswordNeededResponse>("/auth/check-password-needed", {
method: "POST",
body: JSON.stringify(data),
});
}
} }
export const authAPI = new AuthAPI(); export const authAPI = new AuthAPI();

View File

@ -46,6 +46,8 @@ interface AuthState {
setPassword: (email: string, password: string) => Promise<void>; setPassword: (email: string, password: string) => Promise<void>;
requestPasswordReset: (email: string) => Promise<void>; requestPasswordReset: (email: string) => Promise<void>;
resetPassword: (token: string, password: string) => Promise<void>; resetPassword: (token: string, password: string) => Promise<void>;
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
checkPasswordNeeded: (email: string) => Promise<{ needsPasswordSet: boolean; userExists: boolean; email?: string }>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
} }
@ -164,6 +166,32 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
changePassword: async (currentPassword: string, newPassword: string) => {
const { token } = get();
if (!token) throw new Error("Not authenticated");
set({ isLoading: true });
try {
const response = await authAPI.changePassword(token, { currentPassword, newPassword });
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({ isLoading: false });
throw error;
}
},
checkPasswordNeeded: async (email: string) => {
try {
return await authAPI.checkPasswordNeeded({ email });
} catch (error) {
throw error;
}
},
logout: async () => { logout: async () => {
const { token } = get(); const { token } = get();

View File

@ -9,10 +9,12 @@ interface QueryProviderProps {
} }
export function QueryProvider({ children }: QueryProviderProps) { export function QueryProvider({ children }: QueryProviderProps) {
const enableDevtools =
process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === "true" && process.env.NODE_ENV !== "production";
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{children} {children}
<ReactQueryDevtools initialIsOpen={false} /> {enableDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
</QueryClientProvider> </QueryClientProvider>
); );
} }

View File

@ -0,0 +1,12 @@
:root {
/* Card tokens */
--cp-card-radius: 1rem; /* ~rounded-2xl */
--cp-card-padding: 1.25rem; /* ~p-5 */
--cp-card-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); /* shadow-sm */
/* Pill tokens */
--cp-pill-radius: 9999px; /* fully rounded */
--cp-pill-px: 0.5rem; /* 8px */
--cp-pill-py: 0.125rem; /* 2px */
}