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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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> {
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
}

View File

@ -25,6 +25,7 @@ export interface WhmcsClientResponse {
lastname: string;
email: string;
address1?: string;
address2?: string;
city?: string;
state?: string;
postcode?: string;

View File

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

View File

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

View File

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

View File

@ -8,8 +8,6 @@ import {
ArrowLeftIcon,
ArrowRightIcon,
WifiIcon,
CheckIcon,
ExclamationTriangleIcon,
HomeIcon,
BuildingOfficeIcon,
} from "@heroicons/react/24/outline";

View File

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

View File

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

View File

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

View File

@ -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&apos;ll notify you as
soon as it&apos;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&apos;ll receive an email confirmation once approved</li>
<li>We&apos;ll schedule activation based on your preferences</li>
<li>This page will update automatically as your order progresses</li>
</ul>
</div>

View File

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

View File

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

View File

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

View File

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

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