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:
T. Narantuya 2025-08-29 13:26:57 +09:00
parent 7155a6f044
commit 0a387275ff
28 changed files with 637 additions and 179 deletions

View File

@ -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,
}); });

View File

@ -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()

View File

@ -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, [

View File

@ -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",

View File

@ -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"
); );

View File

@ -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

View File

@ -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
}
}
} }

View File

@ -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"
); );

View File

@ -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");

View File

@ -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"
); );

View File

@ -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;
}
} }
} }

View File

@ -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
*/ */

View File

@ -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);
} }

View File

@ -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;

View File

@ -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
*/ */

View File

@ -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>

View File

@ -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 &quot;Platinum Base Plan&quot;. Device
will be added later. subscriptions will be added later.
</p> </p>
</div> </div>
</div> </div>

View File

@ -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";

View File

@ -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> = {};

View File

@ -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&apos;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&apos;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&apos;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"
> >

View File

@ -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"

View File

@ -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&apos;ll notify you as
as it's approved and ready for activation. soon as it&apos;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&apos;ll receive an email confirmation once approved</li>
<li>We'll schedule activation based on your preferences</li> <li>We&apos;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>

View File

@ -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&apos;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"

View File

@ -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&apos;s delivered and set up
</p> </p>
</div> </div>
</label> </label>

View File

@ -1,4 +1,4 @@
import { useState } from "react"; import React from "react";
interface MnpData { interface MnpData {
reservationNumber: string; reservationNumber: string;

View File

@ -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">

View File

@ -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}</>;
}

View 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,
};
}