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:
parent
0f7d680782
commit
cc2a6a3046
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 } }) {
|
||||||
|
|||||||
@ -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."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/bff/src/auth/dto/account-status.dto.ts
Normal file
21
apps/bff/src/auth/dto/account-status.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
16
apps/bff/src/auth/dto/change-password.dto.ts
Normal file
16
apps/bff/src/auth/dto/change-password.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
41
apps/bff/src/users/dto/update-address.dto.ts
Normal file
41
apps/bff/src/users/dto/update-address.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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]";
|
||||||
|
|||||||
@ -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,
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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've configured your service and reviewed all details. Your order will be
|
You’re almost done. Confirm your details above, then submit your order. We’ll 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'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'll receive confirmation once everything is ready</p>
|
<p>• You’ll 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'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>
|
||||||
|
|||||||
@ -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 *));
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
34
apps/portal/src/components/ui/inline-toast.tsx
Normal file
34
apps/portal/src/components/ui/inline-toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
30
apps/portal/src/components/ui/status-pill.tsx
Normal file
30
apps/portal/src/components/ui/status-pill.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/portal/src/components/ui/sub-card.tsx
Normal file
47
apps/portal/src/components/ui/sub-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/portal/src/hooks/usePaymentRefresh.ts
Normal file
67
apps/portal/src/hooks/usePaymentRefresh.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/portal/src/styles/tokens.css
Normal file
12
apps/portal/src/styles/tokens.css
Normal 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 */
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user