Refactor address handling in AuthService and SignupDto, and enhance order processing with address verification
- Updated AuthService to directly access address fields and added support for address line 2. - Introduced AddressDto in SignupDto for structured address validation. - Modified OrdersController to utilize CreateOrderDto for improved type safety. - Enhanced OrderBuilder to include address snapshot functionality during order creation. - Updated UsersService to handle address updates and added new methods in WHMCS service for client updates. - Improved address confirmation logic in AddressConfirmation component for internet orders.
This commit is contained in:
parent
7155a6f044
commit
0a387275ff
@ -127,11 +127,12 @@ export class AuthService {
|
|||||||
email,
|
email,
|
||||||
companyname: company || "",
|
companyname: company || "",
|
||||||
phonenumber: phone || "",
|
phonenumber: phone || "",
|
||||||
address1: address?.line1,
|
address1: address.line1,
|
||||||
city: address?.city,
|
address2: address.line2 || "",
|
||||||
state: address?.state,
|
city: address.city,
|
||||||
postcode: address?.postalCode,
|
state: address.state,
|
||||||
country: address?.country,
|
postcode: address.postalCode,
|
||||||
|
country: address.country,
|
||||||
password2: password, // WHMCS requires plain password for new clients
|
password2: password, // WHMCS requires plain password for new clients
|
||||||
customfields,
|
customfields,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,47 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsOptional, Matches, IsIn } from "class-validator";
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
IsOptional,
|
||||||
|
Matches,
|
||||||
|
IsIn,
|
||||||
|
ValidateNested,
|
||||||
|
IsNotEmpty,
|
||||||
|
} from "class-validator";
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
import { ApiProperty } from "@nestjs/swagger";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class AddressDto {
|
||||||
|
@ApiProperty({ example: "123 Main Street" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
line1: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "Apt 4B", required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
line2?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "Tokyo" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
city: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "Tokyo" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "100-0001" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
postalCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: "JP", description: "ISO 2-letter country code" })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SignupDto {
|
export class SignupDto {
|
||||||
@ApiProperty({ example: "user@example.com" })
|
@ApiProperty({ example: "user@example.com" })
|
||||||
@ -41,16 +83,10 @@ export class SignupDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
sfNumber: string;
|
sfNumber: string;
|
||||||
|
|
||||||
@ApiProperty({ required: false, description: "Address for WHMCS client" })
|
@ApiProperty({ description: "Address for WHMCS client (required)" })
|
||||||
@IsOptional()
|
@ValidateNested()
|
||||||
address?: {
|
@Type(() => AddressDto)
|
||||||
line1: string;
|
address: AddressDto;
|
||||||
line2?: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postalCode: string;
|
|
||||||
country: string; // ISO 2-letter
|
|
||||||
};
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
import { Reflector } from "@nestjs/core";
|
import { Reflector } from "@nestjs/core";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
import { ExtractJwt } from "passport-jwt";
|
import { ExtractJwt } from "passport-jwt";
|
||||||
import { Request } from "express";
|
|
||||||
import { TokenBlacklistService } from "../services/token-blacklist.service";
|
import { TokenBlacklistService } from "../services/token-blacklist.service";
|
||||||
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
|
||||||
|
|
||||||
@ -24,8 +24,12 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest<Request & { route?: { path: string } }>();
|
const request = context.switchToHttp().getRequest<{
|
||||||
const route = `${request.method} ${request.route?.path || request.url}`;
|
method: string;
|
||||||
|
url: string;
|
||||||
|
route?: { path?: string };
|
||||||
|
}>();
|
||||||
|
const route = `${request.method} ${request.route?.path ?? request.url}`;
|
||||||
|
|
||||||
// Check if the route is marked as public
|
// Check if the route is marked as public
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
|||||||
@ -72,17 +72,11 @@ export type SalesforceFieldMap = {
|
|||||||
// WHMCS integration
|
// WHMCS integration
|
||||||
whmcsOrderId: string;
|
whmcsOrderId: string;
|
||||||
|
|
||||||
// Billing/Shipping snapshot fields
|
// Address fields
|
||||||
|
addressChanged: string;
|
||||||
|
|
||||||
|
// Billing address snapshot fields
|
||||||
billing: {
|
billing: {
|
||||||
contactId: string;
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postalCode: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
shipping: {
|
|
||||||
contactId: string;
|
|
||||||
street: string;
|
street: string;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
@ -177,23 +171,17 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
|
|||||||
// WHMCS integration
|
// WHMCS integration
|
||||||
whmcsOrderId: process.env.ORDER_WHMCS_ORDER_ID_FIELD || "WHMCS_Order_ID__c",
|
whmcsOrderId: process.env.ORDER_WHMCS_ORDER_ID_FIELD || "WHMCS_Order_ID__c",
|
||||||
|
|
||||||
// Billing/Shipping snapshot fields
|
// Address fields
|
||||||
|
addressChanged: process.env.ORDER_ADDRESS_CHANGED_FIELD || "Address_Changed__c",
|
||||||
|
|
||||||
|
// Billing address snapshot fields
|
||||||
billing: {
|
billing: {
|
||||||
contactId: process.env.ORDER_BILL_TO_CONTACT_ID_FIELD || "BillToContactId",
|
|
||||||
street: process.env.ORDER_BILL_TO_STREET_FIELD || "BillToStreet",
|
street: process.env.ORDER_BILL_TO_STREET_FIELD || "BillToStreet",
|
||||||
city: process.env.ORDER_BILL_TO_CITY_FIELD || "BillToCity",
|
city: process.env.ORDER_BILL_TO_CITY_FIELD || "BillToCity",
|
||||||
state: process.env.ORDER_BILL_TO_STATE_FIELD || "BillToState",
|
state: process.env.ORDER_BILL_TO_STATE_FIELD || "BillToState",
|
||||||
postalCode: process.env.ORDER_BILL_TO_POSTAL_CODE_FIELD || "BillToPostalCode",
|
postalCode: process.env.ORDER_BILL_TO_POSTAL_CODE_FIELD || "BillToPostalCode",
|
||||||
country: process.env.ORDER_BILL_TO_COUNTRY_FIELD || "BillToCountry",
|
country: process.env.ORDER_BILL_TO_COUNTRY_FIELD || "BillToCountry",
|
||||||
},
|
},
|
||||||
shipping: {
|
|
||||||
contactId: process.env.ORDER_SHIP_TO_CONTACT_ID_FIELD || "ShipToContactId",
|
|
||||||
street: process.env.ORDER_SHIP_TO_STREET_FIELD || "ShipToStreet",
|
|
||||||
city: process.env.ORDER_SHIP_TO_CITY_FIELD || "ShipToCity",
|
|
||||||
state: process.env.ORDER_SHIP_TO_STATE_FIELD || "ShipToState",
|
|
||||||
postalCode: process.env.ORDER_SHIP_TO_POSTAL_CODE_FIELD || "ShipToPostalCode",
|
|
||||||
country: process.env.ORDER_SHIP_TO_COUNTRY_FIELD || "ShipToCountry",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderItem: {
|
orderItem: {
|
||||||
billingCycle: process.env.ORDER_ITEM_BILLING_CYCLE_FIELD || "Billing_Cycle__c",
|
billingCycle: process.env.ORDER_ITEM_BILLING_CYCLE_FIELD || "Billing_Cycle__c",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||||
import { RequestWithUser } from "../auth/auth.types";
|
import { RequestWithUser } from "../auth/auth.types";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { CreateOrderDto } from "./dto/order.dto";
|
||||||
|
|
||||||
@ApiTags("orders")
|
@ApiTags("orders")
|
||||||
@Controller("orders")
|
@Controller("orders")
|
||||||
@ -17,12 +18,12 @@ export class OrdersController {
|
|||||||
@ApiOperation({ summary: "Create Salesforce Order" })
|
@ApiOperation({ summary: "Create Salesforce Order" })
|
||||||
@ApiResponse({ status: 201, description: "Order created successfully" })
|
@ApiResponse({ status: 201, description: "Order created successfully" })
|
||||||
@ApiResponse({ status: 400, description: "Invalid request data" })
|
@ApiResponse({ status: 400, description: "Invalid request data" })
|
||||||
async create(@Request() req: RequestWithUser, @Body() body: unknown) {
|
async create(@Request() req: RequestWithUser, @Body() body: CreateOrderDto) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
{
|
{
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
orderType: (body as any)?.orderType,
|
orderType: body.orderType,
|
||||||
skuCount: (body as any)?.skus?.length || 0,
|
skuCount: body.skus?.length || 0,
|
||||||
},
|
},
|
||||||
"Order creation request received"
|
"Order creation request received"
|
||||||
);
|
);
|
||||||
@ -34,7 +35,7 @@ export class OrdersController {
|
|||||||
{
|
{
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
userId: req.user?.id,
|
userId: req.user?.id,
|
||||||
orderType: (body as any)?.orderType,
|
orderType: body.orderType,
|
||||||
},
|
},
|
||||||
"Order creation failed"
|
"Order creation failed"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { OrdersController } from "./orders.controller";
|
import { OrdersController } from "./orders.controller";
|
||||||
import { VendorsModule } from "../vendors/vendors.module";
|
import { VendorsModule } from "../vendors/vendors.module";
|
||||||
import { MappingsModule } from "../mappings/mappings.module";
|
import { MappingsModule } from "../mappings/mappings.module";
|
||||||
|
import { UsersModule } from "../users/users.module";
|
||||||
|
|
||||||
// Clean modular order services
|
// Clean modular order services
|
||||||
import { OrderValidator } from "./services/order-validator.service";
|
import { OrderValidator } from "./services/order-validator.service";
|
||||||
@ -10,7 +11,7 @@ import { OrderItemBuilder } from "./services/order-item-builder.service";
|
|||||||
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
import { OrderOrchestrator } from "./services/order-orchestrator.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [VendorsModule, MappingsModule],
|
imports: [VendorsModule, MappingsModule, UsersModule],
|
||||||
controllers: [OrdersController],
|
controllers: [OrdersController],
|
||||||
providers: [
|
providers: [
|
||||||
// Clean architecture only
|
// Clean architecture only
|
||||||
|
|||||||
@ -1,20 +1,28 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
import { CreateOrderBody, UserMapping } from "../dto/order.dto";
|
import { CreateOrderBody, UserMapping } from "../dto/order.dto";
|
||||||
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||||
|
import { UsersService } from "../../users/users.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles building order header data from selections
|
* Handles building order header data from selections
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderBuilder {
|
export class OrderBuilder {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly usersService: UsersService
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build order fields for Salesforce Order creation
|
* Build order fields for Salesforce Order creation
|
||||||
*/
|
*/
|
||||||
buildOrderFields(
|
async buildOrderFields(
|
||||||
body: CreateOrderBody,
|
body: CreateOrderBody,
|
||||||
userMapping: UserMapping,
|
userMapping: UserMapping,
|
||||||
pricebookId: string
|
pricebookId: string,
|
||||||
): Record<string, unknown> {
|
userId: string
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||||
const fields = getSalesforceFieldMap();
|
const fields = getSalesforceFieldMap();
|
||||||
|
|
||||||
@ -43,9 +51,8 @@ export class OrderBuilder {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add address snapshot from WHMCS (authoritative source)
|
// Add address snapshot (single address for both billing and shipping)
|
||||||
// Note: We'll need to pass userId separately or get it from the userMapping
|
await this.addAddressSnapshot(orderFields, userId, body);
|
||||||
// For now, skip address snapshot until we have proper user ID access
|
|
||||||
|
|
||||||
return orderFields;
|
return orderFields;
|
||||||
}
|
}
|
||||||
@ -131,8 +138,68 @@ export class OrderBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private addVpnFields(orderFields: Record<string, unknown>, body: CreateOrderBody): void {
|
private addVpnFields(_orderFields: Record<string, unknown>, _body: CreateOrderBody): void {
|
||||||
// Note: Removed vpnRegion field - can be derived from service product metadata in OrderItems
|
// Note: Removed vpnRegion field - can be derived from service product metadata in OrderItems
|
||||||
// VPN orders only need user configuration choices (none currently defined)
|
// VPN orders only need user configuration choices (none currently defined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add address snapshot to order
|
||||||
|
* Always captures current address in billing fields and flags if changed
|
||||||
|
*/
|
||||||
|
private async addAddressSnapshot(
|
||||||
|
orderFields: Record<string, unknown>,
|
||||||
|
userId: string,
|
||||||
|
body: CreateOrderBody
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const fields = getSalesforceFieldMap();
|
||||||
|
const billingInfo = await this.usersService.getBillingInfo(userId);
|
||||||
|
|
||||||
|
// Check if address was provided/updated in the order request
|
||||||
|
const orderAddress = (body.configurations as Record<string, unknown>)?.address as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const addressChanged = !!orderAddress;
|
||||||
|
|
||||||
|
// Use order address if provided, otherwise use current WHMCS address
|
||||||
|
const addressToUse = orderAddress || billingInfo.address;
|
||||||
|
|
||||||
|
// Always populate billing address fields (even if empty)
|
||||||
|
// Combine street and streetLine2 for Salesforce BillToStreet field
|
||||||
|
const street = typeof addressToUse?.street === "string" ? addressToUse.street : "";
|
||||||
|
const streetLine2 =
|
||||||
|
typeof addressToUse?.streetLine2 === "string" ? addressToUse.streetLine2 : "";
|
||||||
|
const fullStreet = [street, streetLine2].filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
orderFields[fields.order.billing.street] = fullStreet || "";
|
||||||
|
orderFields[fields.order.billing.city] =
|
||||||
|
typeof addressToUse?.city === "string" ? addressToUse.city : "";
|
||||||
|
orderFields[fields.order.billing.state] =
|
||||||
|
typeof addressToUse?.state === "string" ? addressToUse.state : "";
|
||||||
|
orderFields[fields.order.billing.postalCode] =
|
||||||
|
typeof addressToUse?.postalCode === "string" ? addressToUse.postalCode : "";
|
||||||
|
orderFields[fields.order.billing.country] =
|
||||||
|
typeof addressToUse?.country === "string" ? addressToUse.country : "";
|
||||||
|
|
||||||
|
// Set Address_Changed flag if customer updated address during checkout
|
||||||
|
orderFields[fields.order.addressChanged] = addressChanged;
|
||||||
|
|
||||||
|
if (addressChanged) {
|
||||||
|
this.logger.log({ userId }, "Customer updated address during checkout");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
hasAddress: !!street,
|
||||||
|
addressChanged,
|
||||||
|
},
|
||||||
|
"Address snapshot added to order"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ userId, error }, "Failed to add address snapshot to order");
|
||||||
|
// Don't fail the order creation, but log the issue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,7 +139,7 @@ export class OrderItemBuilder {
|
|||||||
{
|
{
|
||||||
sku,
|
sku,
|
||||||
found: !!res.records?.length,
|
found: !!res.records?.length,
|
||||||
hasPrice: !!(res.records?.[0] as any)?.UnitPrice,
|
hasPrice: !!(res.records?.[0] as { UnitPrice?: number })?.UnitPrice,
|
||||||
},
|
},
|
||||||
"PricebookEntry query result"
|
"PricebookEntry query result"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -43,8 +43,13 @@ export class OrderOrchestrator {
|
|||||||
"Order validation completed successfully"
|
"Order validation completed successfully"
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) Build order fields (simplified - no address snapshot for now)
|
// 2) Build order fields (includes address snapshot)
|
||||||
const orderFields = this.orderBuilder.buildOrderFields(validatedBody, userMapping, pricebookId);
|
const orderFields = await this.orderBuilder.buildOrderFields(
|
||||||
|
validatedBody,
|
||||||
|
userMapping,
|
||||||
|
pricebookId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
|
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,8 @@ export class OrderValidator {
|
|||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
{
|
{
|
||||||
bodyType: typeof rawBody,
|
bodyType: typeof rawBody,
|
||||||
hasOrderType: !!(rawBody as any)?.orderType,
|
hasOrderType: !!(rawBody as Record<string, unknown>)?.orderType,
|
||||||
hasSkus: !!(rawBody as any)?.skus,
|
hasSkus: !!(rawBody as Record<string, unknown>)?.skus,
|
||||||
},
|
},
|
||||||
"Starting request format validation"
|
"Starting request format validation"
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,8 +6,10 @@ import { User, Activity } from "@customer-portal/shared";
|
|||||||
import { User as PrismaUser } from "@prisma/client";
|
import { User as PrismaUser } from "@prisma/client";
|
||||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||||
import { SalesforceService } from "../vendors/salesforce/salesforce.service";
|
import { SalesforceService } from "../vendors/salesforce/salesforce.service";
|
||||||
|
import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types";
|
||||||
// Removed unused import: getSalesforceFieldMap
|
// Removed unused import: getSalesforceFieldMap
|
||||||
import { MappingsService } from "../mappings/mappings.service";
|
import { MappingsService } from "../mappings/mappings.service";
|
||||||
|
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
||||||
|
|
||||||
// Enhanced type definitions for better type safety
|
// Enhanced type definitions for better type safety
|
||||||
export interface EnhancedUser extends Omit<User, "createdAt" | "updatedAt"> {
|
export interface EnhancedUser extends Omit<User, "createdAt" | "updatedAt"> {
|
||||||
@ -574,7 +576,7 @@ export class UsersService {
|
|||||||
phone: clientDetails.phonenumber || null,
|
phone: clientDetails.phonenumber || null,
|
||||||
address: {
|
address: {
|
||||||
street: clientDetails.address1 || null,
|
street: clientDetails.address1 || null,
|
||||||
streetLine2: null, // address2 not available in current WHMCS response
|
streetLine2: clientDetails.address2 || null,
|
||||||
city: clientDetails.city || null,
|
city: clientDetails.city || null,
|
||||||
state: clientDetails.state || null,
|
state: clientDetails.state || null,
|
||||||
postalCode: clientDetails.postcode || null,
|
postalCode: clientDetails.postcode || null,
|
||||||
@ -598,11 +600,50 @@ export class UsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update billing information in WHMCS (authoritative source)
|
* Update billing information in WHMCS (authoritative source)
|
||||||
* TODO: Implement WHMCS client update functionality
|
|
||||||
*/
|
*/
|
||||||
async updateBillingInfo(userId: string, billingData: any) {
|
async updateBillingInfo(userId: string, billingData: UpdateBillingDto): Promise<void> {
|
||||||
// For now, return current billing info since WHMCS update is not implemented
|
try {
|
||||||
this.logger.warn(`Billing update requested for ${userId} but WHMCS update not implemented`);
|
// Get user mapping
|
||||||
throw new Error("Billing update functionality not yet implemented");
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (!mapping) {
|
||||||
|
throw new NotFoundException("User mapping not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare WHMCS update data
|
||||||
|
const whmcsUpdateData: Partial<WhmcsClientResponse["client"]> = {};
|
||||||
|
|
||||||
|
if (billingData.street !== undefined) {
|
||||||
|
whmcsUpdateData.address1 = billingData.street;
|
||||||
|
}
|
||||||
|
if (billingData.streetLine2 !== undefined) {
|
||||||
|
whmcsUpdateData.address2 = billingData.streetLine2;
|
||||||
|
}
|
||||||
|
if (billingData.city !== undefined) {
|
||||||
|
whmcsUpdateData.city = billingData.city;
|
||||||
|
}
|
||||||
|
if (billingData.state !== undefined) {
|
||||||
|
whmcsUpdateData.state = billingData.state;
|
||||||
|
}
|
||||||
|
if (billingData.postalCode !== undefined) {
|
||||||
|
whmcsUpdateData.postcode = billingData.postalCode;
|
||||||
|
}
|
||||||
|
if (billingData.country !== undefined) {
|
||||||
|
whmcsUpdateData.country = billingData.country;
|
||||||
|
}
|
||||||
|
if (billingData.phone !== undefined) {
|
||||||
|
whmcsUpdateData.phonenumber = billingData.phone;
|
||||||
|
}
|
||||||
|
if (billingData.company !== undefined) {
|
||||||
|
whmcsUpdateData.companyname = billingData.company;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in WHMCS
|
||||||
|
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
|
||||||
|
|
||||||
|
this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error({ userId, error }, "Failed to update billing information");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,29 @@ export class WhmcsClientService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update client details
|
||||||
|
*/
|
||||||
|
async updateClient(
|
||||||
|
clientId: number,
|
||||||
|
updateData: Partial<WhmcsClientResponse["client"]>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.connectionService.updateClient(clientId, updateData);
|
||||||
|
|
||||||
|
// Invalidate cache after update
|
||||||
|
await this.cacheService.invalidateUserCache(clientId.toString());
|
||||||
|
|
||||||
|
this.logger.log(`Successfully updated WHMCS client ${clientId}`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
this.logger.error(`Failed to update WHMCS client ${clientId}`, {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
updateData,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add new client
|
* Add new client
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -291,6 +291,16 @@ export class WhmcsConnectionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateClient(
|
||||||
|
clientId: number,
|
||||||
|
updateData: Partial<WhmcsClientResponse["client"]>
|
||||||
|
): Promise<{ result: string }> {
|
||||||
|
return this.makeRequest<{ result: string }>("UpdateClient", {
|
||||||
|
clientid: clientId,
|
||||||
|
...updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
||||||
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export interface WhmcsClientResponse {
|
|||||||
lastname: string;
|
lastname: string;
|
||||||
email: string;
|
email: string;
|
||||||
address1?: string;
|
address1?: string;
|
||||||
|
address2?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
|
|||||||
10
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
10
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
@ -176,6 +176,16 @@ export class WhmcsService {
|
|||||||
return this.clientService.getClientDetailsByEmail(email);
|
return this.clientService.getClientDetailsByEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update client details in WHMCS
|
||||||
|
*/
|
||||||
|
async updateClient(
|
||||||
|
clientId: number,
|
||||||
|
updateData: Partial<WhmcsClientResponse["client"]>
|
||||||
|
): Promise<void> {
|
||||||
|
return this.clientService.updateClient(clientId, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add new client
|
* Add new client
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
@ -30,6 +31,9 @@ interface BillingInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BillingPage() {
|
export default function BillingPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const isCompletionFlow = searchParams.get("complete") === "true";
|
||||||
|
|
||||||
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 [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@ -38,7 +42,7 @@ export default function BillingPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBillingInfo();
|
void fetchBillingInfo();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchBillingInfo = async () => {
|
const fetchBillingInfo = async () => {
|
||||||
@ -70,10 +74,45 @@ export default function BillingPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!editedAddress) return;
|
if (!editedAddress) return;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const isComplete = !!(
|
||||||
|
editedAddress.street?.trim() &&
|
||||||
|
editedAddress.city?.trim() &&
|
||||||
|
editedAddress.state?.trim() &&
|
||||||
|
editedAddress.postalCode?.trim() &&
|
||||||
|
editedAddress.country?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
setError("Please fill in all required address fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
// TODO: Implement when WHMCS update is available
|
setError(null);
|
||||||
setError("Address updates are not yet implemented. This feature is coming soon.");
|
|
||||||
|
// Update address via API
|
||||||
|
await authenticatedApi.patch("/users/billing", {
|
||||||
|
street: editedAddress.street,
|
||||||
|
streetLine2: editedAddress.streetLine2,
|
||||||
|
city: editedAddress.city,
|
||||||
|
state: editedAddress.state,
|
||||||
|
postalCode: editedAddress.postalCode,
|
||||||
|
country: editedAddress.country,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
if (billingInfo) {
|
||||||
|
setBillingInfo({
|
||||||
|
...billingInfo,
|
||||||
|
address: editedAddress,
|
||||||
|
isComplete: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(false);
|
||||||
|
setEditedAddress(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to update address");
|
setError(err instanceof Error ? err.message : "Failed to update address");
|
||||||
} finally {
|
} finally {
|
||||||
@ -107,9 +146,30 @@ export default function BillingPage() {
|
|||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="flex items-center space-x-3 mb-6">
|
<div className="flex items-center space-x-3 mb-6">
|
||||||
<CreditCardIcon className="h-8 w-8 text-blue-600" />
|
<CreditCardIcon className="h-8 w-8 text-blue-600" />
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Billing & Address</h1>
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCompletionFlow && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900 mb-2">
|
||||||
|
Profile Completion Required
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800">
|
||||||
|
Please review and complete your address information to access all features and
|
||||||
|
enable service ordering.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
@ -128,7 +188,7 @@ export default function BillingPage() {
|
|||||||
<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-6 w-6 text-blue-600" />
|
<MapPinIcon className="h-6 w-6 text-blue-600" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
|
<h2 className="text-xl font-semibold text-gray-900">Service Address</h2>
|
||||||
</div>
|
</div>
|
||||||
{billingInfo?.isComplete && !editing && (
|
{billingInfo?.isComplete && !editing && (
|
||||||
<button
|
<button
|
||||||
@ -141,18 +201,7 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!billingInfo?.isComplete && !editing && (
|
{/* Address is required at signup, so this should rarely be needed */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-yellow-800">
|
|
||||||
Your address is incomplete. Please complete it to enable service ordering.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -261,7 +310,7 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
<div className="flex items-center space-x-3 pt-4">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
className="flex items-center space-x-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors"
|
||||||
>
|
>
|
||||||
@ -365,7 +414,8 @@ export default function BillingPage() {
|
|||||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
<strong>Note:</strong> Contact information is managed through your account settings.
|
<strong>Note:</strong> Contact information is managed through your account settings.
|
||||||
Address updates will be available soon.
|
Address changes are synchronized with our billing system. This address is used for
|
||||||
|
both billing and service delivery.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,17 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { PageLayout } from "@/components/layout/page-layout";
|
import { PageLayout } from "@/components/layout/page-layout";
|
||||||
import {
|
import { ServerIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
ServerIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
CurrencyYenIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ArrowRightIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import { InternetPlan, InternetAddon, InternetInstallation } from "@/shared/types/catalog.types";
|
import { InternetPlan, InternetAddon, InternetInstallation } from "@/shared/types/catalog.types";
|
||||||
import { OrderSummary } from "@/components/catalog/order-summary";
|
|
||||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
import { AddonGroup } from "@/components/catalog/addon-group";
|
||||||
import { InstallationOptions } from "@/components/catalog/installation-options";
|
import { InstallationOptions } from "@/components/catalog/installation-options";
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
||||||
@ -116,9 +110,9 @@ function InternetConfigureContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [planSku, router]);
|
}, [planSku, router, searchParams]);
|
||||||
|
|
||||||
const canContinue = plan && mode && installPlan;
|
// const canContinue = plan && mode && installPlan;
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ number: 1, title: "Service Details", completed: currentStep > 1 },
|
{ number: 1, title: "Service Details", completed: currentStep > 1 },
|
||||||
@ -305,8 +299,8 @@ function InternetConfigureContent() {
|
|||||||
information from our tech team for details.
|
information from our tech team for details.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-yellow-700 mt-2">
|
<p className="text-xs text-yellow-700 mt-2">
|
||||||
* Will appear on the invoice as "Platinum Base Plan". Device subscriptions
|
* Will appear on the invoice as "Platinum Base Plan". Device
|
||||||
will be added later.
|
subscriptions will be added later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
CheckIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
BuildingOfficeIcon,
|
BuildingOfficeIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|||||||
@ -8,15 +8,13 @@ import {
|
|||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
InformationCircleIcon,
|
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
CheckCircleIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { SimPlan, SimActivationFee, SimAddon } from "@/shared/types/catalog.types";
|
import { SimPlan, SimActivationFee, SimAddon } from "@/shared/types/catalog.types";
|
||||||
import { OrderSummary } from "@/components/catalog/order-summary";
|
|
||||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
import { AddonGroup } from "@/components/catalog/addon-group";
|
||||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
||||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
import { AnimatedCard } from "@/components/catalog/animated-card";
|
||||||
@ -165,7 +163,7 @@ function SimConfigureContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [planId]);
|
}, [planId, searchParams]);
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|||||||
@ -206,12 +206,15 @@ function CheckoutContent() {
|
|||||||
if (selections.portingDateOfBirth)
|
if (selections.portingDateOfBirth)
|
||||||
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
||||||
|
|
||||||
|
// Include address in configurations if it was updated during checkout
|
||||||
|
if (confirmedAddress) {
|
||||||
|
configurations.address = confirmedAddress;
|
||||||
|
}
|
||||||
|
|
||||||
const orderData = {
|
const orderData = {
|
||||||
orderType,
|
orderType,
|
||||||
skus: skus,
|
skus: skus,
|
||||||
...(Object.keys(configurations).length > 0 && { configurations }),
|
...(Object.keys(configurations).length > 0 && { configurations }),
|
||||||
// Include address if it was updated during checkout
|
|
||||||
...(confirmedAddress && { address: confirmedAddress }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
||||||
@ -283,6 +286,7 @@ function CheckoutContent() {
|
|||||||
<AddressConfirmation
|
<AddressConfirmation
|
||||||
onAddressConfirmed={handleAddressConfirmed}
|
onAddressConfirmed={handleAddressConfirmed}
|
||||||
onAddressIncomplete={handleAddressIncomplete}
|
onAddressIncomplete={handleAddressIncomplete}
|
||||||
|
orderType={orderType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Order Submission Message */}
|
{/* Order Submission Message */}
|
||||||
@ -292,16 +296,16 @@ function CheckoutContent() {
|
|||||||
</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">Submit Your Order for Review</h2>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
You've configured your service and reviewed all details. Your order will be submitted
|
You've configured your service and reviewed all details. Your order will be
|
||||||
for review and approval.
|
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">
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
|
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
|
||||||
<div className="text-sm text-gray-600 space-y-1">
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
<p>• Your order will be reviewed by our team</p>
|
<p>• Your order will be reviewed by our team</p>
|
||||||
<p>• We'll set up your services in our system</p>
|
<p>• We'll set up your services in our system</p>
|
||||||
<p>• Payment will be processed using your card on file</p>
|
<p>• Payment will be processed using your card on file</p>
|
||||||
<p>• You'll receive confirmation once everything is ready</p>
|
<p>• You'll receive confirmation once everything is ready</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -368,7 +372,7 @@ function CheckoutContent() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmitOrder}
|
onClick={() => void handleSubmitOrder()}
|
||||||
disabled={submitting || checkoutState.orderItems.length === 0 || !addressConfirmed}
|
disabled={submitting || checkoutState.orderItems.length === 0 || !addressConfirmed}
|
||||||
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
import { useAuthStore } from "@/lib/auth/store";
|
import { useAuthStore } from "@/lib/auth/store";
|
||||||
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||||
|
|
||||||
import type { Activity } from "@customer-portal/shared";
|
import type { Activity } from "@customer-portal/shared";
|
||||||
import {
|
import {
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
@ -38,6 +39,7 @@ import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
|||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
|
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||||
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
||||||
|
|
||||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -153,7 +155,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Recent Orders"
|
title="Recent Orders"
|
||||||
value={(summary?.stats as any)?.recentOrders || 0}
|
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
|
||||||
icon={ClipboardDocumentListIconSolid}
|
icon={ClipboardDocumentListIconSolid}
|
||||||
gradient="from-indigo-500 to-purple-500"
|
gradient="from-indigo-500 to-purple-500"
|
||||||
href="/orders"
|
href="/orders"
|
||||||
|
|||||||
@ -83,8 +83,8 @@ export default function OrderStatusPage() {
|
|||||||
Order Submitted Successfully!
|
Order Submitted Successfully!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-green-800 mb-3">
|
<p className="text-green-800 mb-3">
|
||||||
Your order has been created and submitted for processing. We'll notify you as soon
|
Your order has been created and submitted for processing. We'll notify you as
|
||||||
as it's approved and ready for activation.
|
soon as it's approved and ready for activation.
|
||||||
</p>
|
</p>
|
||||||
<div className="text-sm text-green-700">
|
<div className="text-sm text-green-700">
|
||||||
<p className="mb-1">
|
<p className="mb-1">
|
||||||
@ -92,8 +92,8 @@ export default function OrderStatusPage() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 ml-4">
|
<ul className="list-disc list-inside space-y-1 ml-4">
|
||||||
<li>Our team will review your order (usually within 1-2 business days)</li>
|
<li>Our team will review your order (usually within 1-2 business days)</li>
|
||||||
<li>You'll receive an email confirmation once approved</li>
|
<li>You'll receive an email confirmation once approved</li>
|
||||||
<li>We'll schedule activation based on your preferences</li>
|
<li>We'll schedule activation based on your preferences</li>
|
||||||
<li>This page will update automatically as your order progresses</li>
|
<li>This page will update automatically as your order progresses</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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";
|
||||||
@ -24,13 +24,33 @@ interface OrderSummary {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OrdersSuccessBanner() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const showSuccess = searchParams.get("status") === "success";
|
||||||
|
if (!showSuccess) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-green-900 mb-2">
|
||||||
|
Order Submitted Successfully!
|
||||||
|
</h3>
|
||||||
|
<p className="text-green-800">
|
||||||
|
Your order has been created and is now being processed. You can track its progress
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
||||||
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 showSuccess = searchParams.get("status") === "success";
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOrders = async () => {
|
const fetchOrders = async () => {
|
||||||
@ -65,23 +85,10 @@ export default function OrdersPage() {
|
|||||||
title="My Orders"
|
title="My Orders"
|
||||||
description="View and track all your orders"
|
description="View and track all your orders"
|
||||||
>
|
>
|
||||||
{/* Success Banner */}
|
{/* Success Banner (Suspense for useSearchParams) */}
|
||||||
{showSuccess && (
|
<Suspense fallback={null}>
|
||||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 mb-6">
|
<OrdersSuccessBanner />
|
||||||
<div className="flex items-start">
|
</Suspense>
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-green-900 mb-2">
|
|
||||||
Order Submitted Successfully!
|
|
||||||
</h3>
|
|
||||||
<p className="text-green-800">
|
|
||||||
Your order has been created and is now being processed. You can track its progress
|
|
||||||
below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||||
@ -98,7 +105,7 @@ export default function OrdersPage() {
|
|||||||
<div className="bg-white border rounded-xl p-8 text-center">
|
<div className="bg-white border rounded-xl p-8 text-center">
|
||||||
<ClipboardDocumentListIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<ClipboardDocumentListIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders yet</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No orders yet</h3>
|
||||||
<p className="text-gray-600 mb-4">You haven't placed any orders yet.</p>
|
<p className="text-gray-600 mb-4">You haven't placed any orders yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/catalog")}
|
onClick={() => router.push("/catalog")}
|
||||||
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-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export function ActivationForm({
|
|||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">Immediate Activation</span>
|
<span className="font-medium text-gray-900">Immediate Activation</span>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Activate your SIM card as soon as it's delivered and set up
|
Activate your SIM card as soon as it's delivered and set up
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface MnpData {
|
interface MnpData {
|
||||||
reservationNumber: string;
|
reservationNumber: string;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
@ -30,39 +30,51 @@ interface BillingInfo {
|
|||||||
interface AddressConfirmationProps {
|
interface AddressConfirmationProps {
|
||||||
onAddressConfirmed: (address?: Address) => void;
|
onAddressConfirmed: (address?: Address) => void;
|
||||||
onAddressIncomplete: () => void;
|
onAddressIncomplete: () => void;
|
||||||
|
orderType?: string; // Add order type to customize behavior
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddressConfirmation({
|
export function AddressConfirmation({
|
||||||
onAddressConfirmed,
|
onAddressConfirmed,
|
||||||
onAddressIncomplete,
|
onAddressIncomplete,
|
||||||
|
orderType,
|
||||||
}: 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);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
|
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const isInternetOrder = orderType === "Internet";
|
||||||
fetchBillingInfo();
|
const requiresAddressVerification = isInternetOrder;
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchBillingInfo = async () => {
|
const fetchBillingInfo = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await authenticatedApi.get<BillingInfo>("/users/billing");
|
const data = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||||
setBillingInfo(data);
|
setBillingInfo(data);
|
||||||
|
|
||||||
if (!data.isComplete) {
|
// Since address is required at signup, it should always be complete
|
||||||
onAddressIncomplete();
|
// But we still need verification for Internet orders
|
||||||
|
if (requiresAddressVerification) {
|
||||||
|
// For Internet orders, don't auto-confirm - require explicit verification
|
||||||
|
setAddressConfirmed(false);
|
||||||
|
onAddressIncomplete(); // Keep disabled until explicitly confirmed
|
||||||
} else {
|
} else {
|
||||||
|
// For other order types, auto-confirm since address exists from signup
|
||||||
onAddressConfirmed(data.address);
|
onAddressConfirmed(data.address);
|
||||||
|
setAddressConfirmed(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load address");
|
setError(err instanceof Error ? err.message : "Failed to load address");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchBillingInfo();
|
||||||
|
}, [fetchBillingInfo]);
|
||||||
|
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@ -78,27 +90,36 @@ export function AddressConfirmation({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = () => {
|
||||||
if (!editedAddress) return;
|
if (!editedAddress) return;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const isComplete = !!(
|
||||||
|
editedAddress.street?.trim() &&
|
||||||
|
editedAddress.city?.trim() &&
|
||||||
|
editedAddress.state?.trim() &&
|
||||||
|
editedAddress.postalCode?.trim() &&
|
||||||
|
editedAddress.country?.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
setError("Please fill in all required address fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For now, just use the edited address for the order
|
setError(null);
|
||||||
// TODO: Implement WHMCS update when available
|
// Use the edited address for the order (will be flagged as changed)
|
||||||
onAddressConfirmed(editedAddress);
|
onAddressConfirmed(editedAddress);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
|
setAddressConfirmed(true);
|
||||||
|
|
||||||
// Update local state to show the new address
|
// Update local state to show the new address
|
||||||
if (billingInfo) {
|
if (billingInfo) {
|
||||||
setBillingInfo({
|
setBillingInfo({
|
||||||
...billingInfo,
|
...billingInfo,
|
||||||
address: editedAddress,
|
address: editedAddress,
|
||||||
isComplete: !!(
|
isComplete: true,
|
||||||
editedAddress.street &&
|
|
||||||
editedAddress.city &&
|
|
||||||
editedAddress.state &&
|
|
||||||
editedAddress.postalCode &&
|
|
||||||
editedAddress.country
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -106,9 +127,17 @@ export function AddressConfirmation({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleConfirmAddress = () => {
|
||||||
|
if (billingInfo?.address) {
|
||||||
|
onAddressConfirmed(billingInfo.address);
|
||||||
|
setAddressConfirmed(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setEditedAddress(null);
|
setEditedAddress(null);
|
||||||
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -131,7 +160,7 @@ export function AddressConfirmation({
|
|||||||
<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
|
||||||
onClick={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"
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
@ -150,7 +179,11 @@ export function AddressConfirmation({
|
|||||||
<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">
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
{billingInfo.isComplete ? "Confirm Delivery Address" : "Complete Your Address"}
|
{isInternetOrder
|
||||||
|
? "Verify Installation Address"
|
||||||
|
: billingInfo.isComplete
|
||||||
|
? "Confirm Service Address"
|
||||||
|
: "Complete Your Address"}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{billingInfo.isComplete && !editing && (
|
{billingInfo.isComplete && !editing && (
|
||||||
@ -164,13 +197,19 @@ export function AddressConfirmation({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!billingInfo.isComplete && (
|
{/* Address should always be complete since it's required at signup */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
|
||||||
|
{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">
|
<div className="flex items-start space-x-3">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
<MapPinIcon className="h-5 w-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-yellow-800">
|
<p className="text-sm text-blue-800">
|
||||||
Please complete your address information to continue with your order.
|
<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. Our
|
||||||
|
technician will visit this location for setup.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -184,11 +223,13 @@ export function AddressConfirmation({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedAddress?.street || ""}
|
value={editedAddress?.street || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, street: e.target.value } : null))
|
setError(null); // Clear error on input
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, street: e.target.value } : null));
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="123 Main Street"
|
placeholder="123 Main Street"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -199,9 +240,10 @@ export function AddressConfirmation({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedAddress?.streetLine2 || ""}
|
value={editedAddress?.streetLine2 || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, streetLine2: e.target.value } : null))
|
setError(null);
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, streetLine2: e.target.value } : null));
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Apartment, suite, etc. (optional)"
|
placeholder="Apartment, suite, etc. (optional)"
|
||||||
/>
|
/>
|
||||||
@ -213,9 +255,10 @@ export function AddressConfirmation({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedAddress?.city || ""}
|
value={editedAddress?.city || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null))
|
setError(null);
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null));
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Tokyo"
|
placeholder="Tokyo"
|
||||||
/>
|
/>
|
||||||
@ -228,9 +271,10 @@ export function AddressConfirmation({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedAddress?.state || ""}
|
value={editedAddress?.state || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null))
|
setError(null);
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null));
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="Tokyo"
|
placeholder="Tokyo"
|
||||||
/>
|
/>
|
||||||
@ -241,9 +285,10 @@ export function AddressConfirmation({
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedAddress?.postalCode || ""}
|
value={editedAddress?.postalCode || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, postalCode: e.target.value } : null))
|
setError(null);
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, postalCode: e.target.value } : null));
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="100-0001"
|
placeholder="100-0001"
|
||||||
/>
|
/>
|
||||||
@ -254,9 +299,10 @@ export function AddressConfirmation({
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
|
||||||
<select
|
<select
|
||||||
value={editedAddress?.country || ""}
|
value={editedAddress?.country || ""}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setEditedAddress(prev => (prev ? { ...prev, country: e.target.value } : null))
|
setError(null);
|
||||||
}
|
setEditedAddress(prev => (prev ? { ...prev, country: e.target.value } : null));
|
||||||
|
}}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Select Country</option>
|
<option value="">Select Country</option>
|
||||||
@ -298,6 +344,38 @@ export function AddressConfirmation({
|
|||||||
</p>
|
</p>
|
||||||
<p>{billingInfo.address.country}</p>
|
<p>{billingInfo.address.country}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Address Confirmation for Internet Orders */}
|
||||||
|
{isInternetOrder && !addressConfirmed && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
|
||||||
|
<span className="text-sm text-amber-700 font-medium">
|
||||||
|
Verification Required
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Address Confirmed Status */}
|
||||||
|
{addressConfirmed && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-sm text-green-700 font-medium">
|
||||||
|
{isInternetOrder ? "Installation Address Confirmed" : "Address Confirmed"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useProfileCompletion } from "@/hooks/use-profile-completion";
|
||||||
|
import { MapPinIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
interface ProfileCompletionGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requireComplete?: boolean;
|
||||||
|
showBanner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCompletionGuard({
|
||||||
|
children,
|
||||||
|
requireComplete = false,
|
||||||
|
showBanner = true,
|
||||||
|
}: ProfileCompletionGuardProps) {
|
||||||
|
const { isComplete, loading, redirectToCompletion } = useProfileCompletion();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If profile completion is required and profile is incomplete, redirect
|
||||||
|
if (!loading && requireComplete && !isComplete) {
|
||||||
|
redirectToCompletion();
|
||||||
|
}
|
||||||
|
}, [loading, requireComplete, isComplete, redirectToCompletion]);
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Loading...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If requiring complete profile and it's not complete, show loading (will redirect)
|
||||||
|
if (requireComplete && !isComplete) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-3 text-gray-600">Redirecting to complete profile...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show banner if profile is incomplete and banner is enabled
|
||||||
|
if (!isComplete && showBanner) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-amber-900 mb-2">Complete Your Profile</h3>
|
||||||
|
<p className="text-amber-800 mb-4">
|
||||||
|
Some features may be limited until you complete your profile information.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={redirectToCompletion}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<MapPinIcon className="h-4 w-4 mr-2" />
|
||||||
|
Complete Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile is complete or banner is disabled, show children
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
61
apps/portal/src/hooks/use-profile-completion.ts
Normal file
61
apps/portal/src/hooks/use-profile-completion.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
|
interface Address {
|
||||||
|
street: string | null;
|
||||||
|
streetLine2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BillingInfo {
|
||||||
|
company: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
address: Address;
|
||||||
|
isComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileCompletionStatus {
|
||||||
|
isComplete: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
redirectToCompletion: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProfileCompletion(): ProfileCompletionStatus {
|
||||||
|
const [isComplete, setIsComplete] = useState<boolean>(true); // Default to true to avoid flash
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkProfileCompletion = async () => {
|
||||||
|
try {
|
||||||
|
const billingInfo = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||||
|
setIsComplete(billingInfo.isComplete);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to check profile completion:", error);
|
||||||
|
// On error, assume incomplete to be safe
|
||||||
|
setIsComplete(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void checkProfileCompletion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const redirectToCompletion = () => {
|
||||||
|
router.push("/account/billing?complete=true");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isComplete,
|
||||||
|
loading,
|
||||||
|
redirectToCompletion,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user