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,
|
||||
companyname: company || "",
|
||||
phonenumber: phone || "",
|
||||
address1: address?.line1,
|
||||
city: address?.city,
|
||||
state: address?.state,
|
||||
postcode: address?.postalCode,
|
||||
country: address?.country,
|
||||
address1: address.line1,
|
||||
address2: address.line2 || "",
|
||||
city: address.city,
|
||||
state: address.state,
|
||||
postcode: address.postalCode,
|
||||
country: address.country,
|
||||
password2: password, // WHMCS requires plain password for new clients
|
||||
customfields,
|
||||
});
|
||||
|
||||
@ -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 { 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 {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@ -41,16 +83,10 @@ export class SignupDto {
|
||||
@IsString()
|
||||
sfNumber: string;
|
||||
|
||||
@ApiProperty({ required: false, description: "Address for WHMCS client" })
|
||||
@IsOptional()
|
||||
address?: {
|
||||
line1: string;
|
||||
line2?: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string; // ISO 2-letter
|
||||
};
|
||||
@ApiProperty({ description: "Address for WHMCS client (required)" })
|
||||
@ValidateNested()
|
||||
@Type(() => AddressDto)
|
||||
address: AddressDto;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ExtractJwt } from "passport-jwt";
|
||||
import { Request } from "express";
|
||||
|
||||
import { TokenBlacklistService } from "../services/token-blacklist.service";
|
||||
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> {
|
||||
const request = context.switchToHttp().getRequest<Request & { route?: { path: string } }>();
|
||||
const route = `${request.method} ${request.route?.path || request.url}`;
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
method: string;
|
||||
url: string;
|
||||
route?: { path?: string };
|
||||
}>();
|
||||
const route = `${request.method} ${request.route?.path ?? request.url}`;
|
||||
|
||||
// Check if the route is marked as public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
|
||||
@ -72,17 +72,11 @@ export type SalesforceFieldMap = {
|
||||
// WHMCS integration
|
||||
whmcsOrderId: string;
|
||||
|
||||
// Billing/Shipping snapshot fields
|
||||
// Address fields
|
||||
addressChanged: string;
|
||||
|
||||
// Billing address snapshot fields
|
||||
billing: {
|
||||
contactId: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
shipping: {
|
||||
contactId: string;
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
@ -177,23 +171,17 @@ export function getSalesforceFieldMap(): SalesforceFieldMap {
|
||||
// WHMCS integration
|
||||
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: {
|
||||
contactId: process.env.ORDER_BILL_TO_CONTACT_ID_FIELD || "BillToContactId",
|
||||
street: process.env.ORDER_BILL_TO_STREET_FIELD || "BillToStreet",
|
||||
city: process.env.ORDER_BILL_TO_CITY_FIELD || "BillToCity",
|
||||
state: process.env.ORDER_BILL_TO_STATE_FIELD || "BillToState",
|
||||
postalCode: process.env.ORDER_BILL_TO_POSTAL_CODE_FIELD || "BillToPostalCode",
|
||||
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: {
|
||||
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 { RequestWithUser } from "../auth/auth.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CreateOrderDto } from "./dto/order.dto";
|
||||
|
||||
@ApiTags("orders")
|
||||
@Controller("orders")
|
||||
@ -17,12 +18,12 @@ export class OrdersController {
|
||||
@ApiOperation({ summary: "Create Salesforce Order" })
|
||||
@ApiResponse({ status: 201, description: "Order created successfully" })
|
||||
@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(
|
||||
{
|
||||
userId: req.user?.id,
|
||||
orderType: (body as any)?.orderType,
|
||||
skuCount: (body as any)?.skus?.length || 0,
|
||||
orderType: body.orderType,
|
||||
skuCount: body.skus?.length || 0,
|
||||
},
|
||||
"Order creation request received"
|
||||
);
|
||||
@ -34,7 +35,7 @@ export class OrdersController {
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
userId: req.user?.id,
|
||||
orderType: (body as any)?.orderType,
|
||||
orderType: body.orderType,
|
||||
},
|
||||
"Order creation failed"
|
||||
);
|
||||
|
||||
@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
||||
import { OrdersController } from "./orders.controller";
|
||||
import { VendorsModule } from "../vendors/vendors.module";
|
||||
import { MappingsModule } from "../mappings/mappings.module";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
|
||||
// Clean modular order services
|
||||
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";
|
||||
|
||||
@Module({
|
||||
imports: [VendorsModule, MappingsModule],
|
||||
imports: [VendorsModule, MappingsModule, UsersModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [
|
||||
// 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 { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||
import { UsersService } from "../../users/users.service";
|
||||
|
||||
/**
|
||||
* Handles building order header data from selections
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrderBuilder {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build order fields for Salesforce Order creation
|
||||
*/
|
||||
buildOrderFields(
|
||||
async buildOrderFields(
|
||||
body: CreateOrderBody,
|
||||
userMapping: UserMapping,
|
||||
pricebookId: string
|
||||
): Record<string, unknown> {
|
||||
pricebookId: string,
|
||||
userId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const fields = getSalesforceFieldMap();
|
||||
|
||||
@ -43,9 +51,8 @@ export class OrderBuilder {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add address snapshot from WHMCS (authoritative source)
|
||||
// Note: We'll need to pass userId separately or get it from the userMapping
|
||||
// For now, skip address snapshot until we have proper user ID access
|
||||
// Add address snapshot (single address for both billing and shipping)
|
||||
await this.addAddressSnapshot(orderFields, userId, body);
|
||||
|
||||
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
|
||||
// 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,
|
||||
found: !!res.records?.length,
|
||||
hasPrice: !!(res.records?.[0] as any)?.UnitPrice,
|
||||
hasPrice: !!(res.records?.[0] as { UnitPrice?: number })?.UnitPrice,
|
||||
},
|
||||
"PricebookEntry query result"
|
||||
);
|
||||
|
||||
@ -43,8 +43,13 @@ export class OrderOrchestrator {
|
||||
"Order validation completed successfully"
|
||||
);
|
||||
|
||||
// 2) Build order fields (simplified - no address snapshot for now)
|
||||
const orderFields = this.orderBuilder.buildOrderFields(validatedBody, userMapping, pricebookId);
|
||||
// 2) Build order fields (includes address snapshot)
|
||||
const orderFields = await this.orderBuilder.buildOrderFields(
|
||||
validatedBody,
|
||||
userMapping,
|
||||
pricebookId,
|
||||
userId
|
||||
);
|
||||
|
||||
this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order");
|
||||
|
||||
|
||||
@ -28,8 +28,8 @@ export class OrderValidator {
|
||||
this.logger.debug(
|
||||
{
|
||||
bodyType: typeof rawBody,
|
||||
hasOrderType: !!(rawBody as any)?.orderType,
|
||||
hasSkus: !!(rawBody as any)?.skus,
|
||||
hasOrderType: !!(rawBody as Record<string, unknown>)?.orderType,
|
||||
hasSkus: !!(rawBody as Record<string, unknown>)?.skus,
|
||||
},
|
||||
"Starting request format validation"
|
||||
);
|
||||
|
||||
@ -6,8 +6,10 @@ import { User, Activity } from "@customer-portal/shared";
|
||||
import { User as PrismaUser } from "@prisma/client";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "../vendors/salesforce/salesforce.service";
|
||||
import { WhmcsClientResponse } from "../vendors/whmcs/types/whmcs-api.types";
|
||||
// Removed unused import: getSalesforceFieldMap
|
||||
import { MappingsService } from "../mappings/mappings.service";
|
||||
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
||||
|
||||
// Enhanced type definitions for better type safety
|
||||
export interface EnhancedUser extends Omit<User, "createdAt" | "updatedAt"> {
|
||||
@ -574,7 +576,7 @@ export class UsersService {
|
||||
phone: clientDetails.phonenumber || null,
|
||||
address: {
|
||||
street: clientDetails.address1 || null,
|
||||
streetLine2: null, // address2 not available in current WHMCS response
|
||||
streetLine2: clientDetails.address2 || null,
|
||||
city: clientDetails.city || null,
|
||||
state: clientDetails.state || null,
|
||||
postalCode: clientDetails.postcode || null,
|
||||
@ -598,11 +600,50 @@ export class UsersService {
|
||||
|
||||
/**
|
||||
* Update billing information in WHMCS (authoritative source)
|
||||
* TODO: Implement WHMCS client update functionality
|
||||
*/
|
||||
async updateBillingInfo(userId: string, billingData: any) {
|
||||
// For now, return current billing info since WHMCS update is not implemented
|
||||
this.logger.warn(`Billing update requested for ${userId} but WHMCS update not implemented`);
|
||||
throw new Error("Billing update functionality not yet implemented");
|
||||
async updateBillingInfo(userId: string, billingData: UpdateBillingDto): Promise<void> {
|
||||
try {
|
||||
// Get user mapping
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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> {
|
||||
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export interface WhmcsClientResponse {
|
||||
lastname: string;
|
||||
email: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
city?: string;
|
||||
state?: 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client details in WHMCS
|
||||
*/
|
||||
async updateClient(
|
||||
clientId: number,
|
||||
updateData: Partial<WhmcsClientResponse["client"]>
|
||||
): Promise<void> {
|
||||
return this.clientService.updateClient(clientId, updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import {
|
||||
@ -30,6 +31,9 @@ interface BillingInfo {
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const isCompletionFlow = searchParams.get("complete") === "true";
|
||||
|
||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@ -38,7 +42,7 @@ export default function BillingPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBillingInfo();
|
||||
void fetchBillingInfo();
|
||||
}, []);
|
||||
|
||||
const fetchBillingInfo = async () => {
|
||||
@ -70,10 +74,45 @@ export default function BillingPage() {
|
||||
const handleSave = async () => {
|
||||
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 {
|
||||
setSaving(true);
|
||||
// TODO: Implement when WHMCS update is available
|
||||
setError("Address updates are not yet implemented. This feature is coming soon.");
|
||||
setError(null);
|
||||
|
||||
// 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) {
|
||||
setError(err instanceof Error ? err.message : "Failed to update address");
|
||||
} finally {
|
||||
@ -107,9 +146,30 @@ export default function BillingPage() {
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<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>
|
||||
|
||||
{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 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<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 space-x-3">
|
||||
<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>
|
||||
{billingInfo?.isComplete && !editing && (
|
||||
<button
|
||||
@ -141,18 +201,7 @@ export default function BillingPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!billingInfo?.isComplete && !editing && (
|
||||
<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>
|
||||
)}
|
||||
{/* Address is required at signup, so this should rarely be needed */}
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
@ -261,7 +310,7 @@ export default function BillingPage() {
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
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"
|
||||
>
|
||||
@ -365,7 +414,8 @@ export default function BillingPage() {
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,17 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
import {
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
CurrencyYenIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ServerIcon, ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import { InternetPlan, InternetAddon, InternetInstallation } from "@/shared/types/catalog.types";
|
||||
import { OrderSummary } from "@/components/catalog/order-summary";
|
||||
|
||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
||||
import { InstallationOptions } from "@/components/catalog/installation-options";
|
||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
||||
@ -116,9 +110,9 @@ function InternetConfigureContent() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [planSku, router]);
|
||||
}, [planSku, router, searchParams]);
|
||||
|
||||
const canContinue = plan && mode && installPlan;
|
||||
// const canContinue = plan && mode && installPlan;
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: "Service Details", completed: currentStep > 1 },
|
||||
@ -305,8 +299,8 @@ function InternetConfigureContent() {
|
||||
information from our tech team for details.
|
||||
</p>
|
||||
<p className="text-xs text-yellow-700 mt-2">
|
||||
* Will appear on the invoice as "Platinum Base Plan". Device subscriptions
|
||||
will be added later.
|
||||
* Will appear on the invoice as "Platinum Base Plan". Device
|
||||
subscriptions will be added later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,8 +8,6 @@ import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
WifiIcon,
|
||||
CheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
HomeIcon,
|
||||
BuildingOfficeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
@ -8,15 +8,13 @@ import {
|
||||
ArrowRightIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
UsersIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import Link from "next/link";
|
||||
|
||||
import { SimPlan, SimActivationFee, SimAddon } from "@/shared/types/catalog.types";
|
||||
import { OrderSummary } from "@/components/catalog/order-summary";
|
||||
|
||||
import { AddonGroup } from "@/components/catalog/addon-group";
|
||||
import { LoadingSpinner } from "@/components/catalog/loading-spinner";
|
||||
import { AnimatedCard } from "@/components/catalog/animated-card";
|
||||
@ -165,7 +163,7 @@ function SimConfigureContent() {
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [planId]);
|
||||
}, [planId, searchParams]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@ -206,12 +206,15 @@ function CheckoutContent() {
|
||||
if (selections.portingDateOfBirth)
|
||||
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
||||
|
||||
// Include address in configurations if it was updated during checkout
|
||||
if (confirmedAddress) {
|
||||
configurations.address = confirmedAddress;
|
||||
}
|
||||
|
||||
const orderData = {
|
||||
orderType,
|
||||
skus: skus,
|
||||
...(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);
|
||||
@ -283,6 +286,7 @@ function CheckoutContent() {
|
||||
<AddressConfirmation
|
||||
onAddressConfirmed={handleAddressConfirmed}
|
||||
onAddressIncomplete={handleAddressIncomplete}
|
||||
orderType={orderType}
|
||||
/>
|
||||
|
||||
{/* Order Submission Message */}
|
||||
@ -292,16 +296,16 @@ function CheckoutContent() {
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You've configured your service and reviewed all details. Your order will be submitted
|
||||
for review and approval.
|
||||
You've configured your service and reviewed all details. Your order will be
|
||||
submitted for review and approval.
|
||||
</p>
|
||||
<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>
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<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>• You'll receive confirmation once everything is ready</p>
|
||||
<p>• You'll receive confirmation once everything is ready</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -368,7 +372,7 @@ function CheckoutContent() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitOrder}
|
||||
onClick={() => void handleSubmitOrder()}
|
||||
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"
|
||||
>
|
||||
|
||||
@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
import { useDashboardSummary } from "@/features/dashboard/hooks";
|
||||
|
||||
import type { Activity } from "@customer-portal/shared";
|
||||
import {
|
||||
CreditCardIcon,
|
||||
@ -38,6 +39,7 @@ import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
|
||||
export default function DashboardPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
|
||||
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
|
||||
|
||||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||||
const [paymentError, setPaymentError] = useState<string | null>(null);
|
||||
|
||||
@ -153,7 +155,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<StatCard
|
||||
title="Recent Orders"
|
||||
value={(summary?.stats as any)?.recentOrders || 0}
|
||||
value={((summary?.stats as Record<string, unknown>)?.recentOrders as number) || 0}
|
||||
icon={ClipboardDocumentListIconSolid}
|
||||
gradient="from-indigo-500 to-purple-500"
|
||||
href="/orders"
|
||||
|
||||
@ -83,8 +83,8 @@ export default function OrderStatusPage() {
|
||||
Order Submitted Successfully!
|
||||
</h3>
|
||||
<p className="text-green-800 mb-3">
|
||||
Your order has been created and submitted for processing. We'll notify you as soon
|
||||
as it's approved and ready for activation.
|
||||
Your order has been created and submitted for processing. We'll notify you as
|
||||
soon as it's approved and ready for activation.
|
||||
</p>
|
||||
<div className="text-sm text-green-700">
|
||||
<p className="mb-1">
|
||||
@ -92,8 +92,8 @@ export default function OrderStatusPage() {
|
||||
</p>
|
||||
<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>You'll receive an email confirmation once approved</li>
|
||||
<li>We'll schedule activation based on your preferences</li>
|
||||
<li>You'll receive an email confirmation once approved</li>
|
||||
<li>We'll schedule activation based on your preferences</li>
|
||||
<li>This page will update automatically as your order progresses</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/layout/page-layout";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [orders, setOrders] = useState<OrderSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const showSuccess = searchParams.get("status") === "success";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
@ -65,23 +85,10 @@ export default function OrdersPage() {
|
||||
title="My Orders"
|
||||
description="View and track all your orders"
|
||||
>
|
||||
{/* Success Banner */}
|
||||
{showSuccess && (
|
||||
<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>
|
||||
)}
|
||||
{/* Success Banner (Suspense for useSearchParams) */}
|
||||
<Suspense fallback={null}>
|
||||
<OrdersSuccessBanner />
|
||||
</Suspense>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
<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>
|
||||
<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
|
||||
onClick={() => router.push("/catalog")}
|
||||
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>
|
||||
<span className="font-medium text-gray-900">Immediate Activation</span>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface MnpData {
|
||||
reservationNumber: string;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
import {
|
||||
MapPinIcon,
|
||||
@ -30,39 +30,51 @@ interface BillingInfo {
|
||||
interface AddressConfirmationProps {
|
||||
onAddressConfirmed: (address?: Address) => void;
|
||||
onAddressIncomplete: () => void;
|
||||
orderType?: string; // Add order type to customize behavior
|
||||
}
|
||||
|
||||
export function AddressConfirmation({
|
||||
onAddressConfirmed,
|
||||
onAddressIncomplete,
|
||||
orderType,
|
||||
}: AddressConfirmationProps) {
|
||||
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editedAddress, setEditedAddress] = useState<Address | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBillingInfo();
|
||||
}, []);
|
||||
const isInternetOrder = orderType === "Internet";
|
||||
const requiresAddressVerification = isInternetOrder;
|
||||
|
||||
const fetchBillingInfo = async () => {
|
||||
const fetchBillingInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||
setBillingInfo(data);
|
||||
|
||||
if (!data.isComplete) {
|
||||
onAddressIncomplete();
|
||||
// Since address is required at signup, it should always be complete
|
||||
// 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 {
|
||||
// For other order types, auto-confirm since address exists from signup
|
||||
onAddressConfirmed(data.address);
|
||||
setAddressConfirmed(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load address");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [requiresAddressVerification, onAddressIncomplete, onAddressConfirmed]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchBillingInfo();
|
||||
}, [fetchBillingInfo]);
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditing(true);
|
||||
@ -78,27 +90,36 @@ export function AddressConfirmation({
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = () => {
|
||||
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 {
|
||||
// For now, just use the edited address for the order
|
||||
// TODO: Implement WHMCS update when available
|
||||
setError(null);
|
||||
// Use the edited address for the order (will be flagged as changed)
|
||||
onAddressConfirmed(editedAddress);
|
||||
setEditing(false);
|
||||
setAddressConfirmed(true);
|
||||
|
||||
// Update local state to show the new address
|
||||
if (billingInfo) {
|
||||
setBillingInfo({
|
||||
...billingInfo,
|
||||
address: editedAddress,
|
||||
isComplete: !!(
|
||||
editedAddress.street &&
|
||||
editedAddress.city &&
|
||||
editedAddress.state &&
|
||||
editedAddress.postalCode &&
|
||||
editedAddress.country
|
||||
),
|
||||
isComplete: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
@ -106,9 +127,17 @@ export function AddressConfirmation({
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmAddress = () => {
|
||||
if (billingInfo?.address) {
|
||||
onAddressConfirmed(billingInfo.address);
|
||||
setAddressConfirmed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false);
|
||||
setEditedAddress(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@ -131,7 +160,7 @@ export function AddressConfirmation({
|
||||
<h3 className="text-sm font-medium text-red-800">Address Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
<button
|
||||
onClick={fetchBillingInfo}
|
||||
onClick={() => void fetchBillingInfo()}
|
||||
className="text-sm text-red-600 hover:text-red-500 font-medium mt-2"
|
||||
>
|
||||
Try Again
|
||||
@ -150,7 +179,11 @@ export function AddressConfirmation({
|
||||
<div className="flex items-center space-x-3">
|
||||
<MapPinIcon className="h-5 w-5 text-blue-600" />
|
||||
<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>
|
||||
</div>
|
||||
{billingInfo.isComplete && !editing && (
|
||||
@ -164,13 +197,19 @@ export function AddressConfirmation({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!billingInfo.isComplete && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
{/* 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">
|
||||
<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>
|
||||
<p className="text-sm text-yellow-800">
|
||||
Please complete your address information to continue with your order.
|
||||
<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. Our
|
||||
technician will visit this location for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -184,11 +223,13 @@ export function AddressConfirmation({
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.street || ""}
|
||||
onChange={e =>
|
||||
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"
|
||||
onChange={e => {
|
||||
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 focus:border-blue-500"
|
||||
placeholder="123 Main Street"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -199,9 +240,10 @@ export function AddressConfirmation({
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.streetLine2 || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, streetLine2: e.target.value } : null))
|
||||
}
|
||||
onChange={e => {
|
||||
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"
|
||||
placeholder="Apartment, suite, etc. (optional)"
|
||||
/>
|
||||
@ -213,9 +255,10 @@ export function AddressConfirmation({
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.city || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, city: e.target.value } : null))
|
||||
}
|
||||
onChange={e => {
|
||||
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"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
@ -228,9 +271,10 @@ export function AddressConfirmation({
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.state || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, state: e.target.value } : null))
|
||||
}
|
||||
onChange={e => {
|
||||
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"
|
||||
placeholder="Tokyo"
|
||||
/>
|
||||
@ -241,9 +285,10 @@ export function AddressConfirmation({
|
||||
<input
|
||||
type="text"
|
||||
value={editedAddress?.postalCode || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, postalCode: e.target.value } : null))
|
||||
}
|
||||
onChange={e => {
|
||||
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"
|
||||
placeholder="100-0001"
|
||||
/>
|
||||
@ -254,9 +299,10 @@ export function AddressConfirmation({
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Country *</label>
|
||||
<select
|
||||
value={editedAddress?.country || ""}
|
||||
onChange={e =>
|
||||
setEditedAddress(prev => (prev ? { ...prev, country: e.target.value } : null))
|
||||
}
|
||||
onChange={e => {
|
||||
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"
|
||||
>
|
||||
<option value="">Select Country</option>
|
||||
@ -298,6 +344,38 @@ export function AddressConfirmation({
|
||||
</p>
|
||||
<p>{billingInfo.address.country}</p>
|
||||
</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 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