Enhance order processing and address management across components

- Updated pnpm-lock.yaml to include new dependencies and ensure consistent package versions.
- Refactored order handling in OrderOrchestrator to utilize centralized field mapping for Salesforce queries.
- Modified SalesforceOrder interface to replace Order_Type__c with Type for clarity.
- Improved error handling in UsersService during WHMCS updates to provide user-friendly messages.
- Enhanced BillingPage and ProfilePage components to streamline address management and improve user experience.
- Updated documentation to reflect changes in address handling and system architecture.
This commit is contained in:
T. Narantuya 2025-08-29 14:05:33 +09:00
parent 6c7b1b531c
commit 4f087d5c4c
23 changed files with 4761 additions and 8299 deletions

View File

@ -6,8 +6,8 @@ A modern customer portal where users can self-register, log in, browse & buy sub
### Systems of Record ### Systems of Record
- **WHMCS**: Billing, subscriptions, and invoices - **WHMCS**: Billing, subscriptions, invoices, and **authoritative address storage**
- **Salesforce**: CRM (Accounts, Contacts, Cases) - **Salesforce**: CRM (Accounts, Contacts, Cases) and **order address snapshots**
- **Portal**: Modern UI with backend for frontend (BFF) architecture - **Portal**: Modern UI with backend for frontend (BFF) architecture
### Identity Management ### Identity Management
@ -374,6 +374,24 @@ rm -rf node_modules && pnpm install
- Implement clean, minimal UI designs [[memory:6676820]] - Implement clean, minimal UI designs [[memory:6676820]]
- Avoid 'V2' suffixes in service names [[memory:6676816]] - Avoid 'V2' suffixes in service names [[memory:6676816]]
## Documentation
📚 **[Complete Documentation](docs/README.md)** - Full documentation index
### Quick Links
- **[Getting Started](docs/GETTING_STARTED.md)** - Setup and configuration
- **[Development Commands](docs/RUN.md)** - Daily workflow
- **[Address System](docs/ADDRESS_SYSTEM.md)** - Address management
- **[Product Catalog](docs/PRODUCT-CATALOG-ARCHITECTURE.md)** - SKU-based catalog
- **[Deployment Guide](docs/DEPLOY.md)** - Production deployment
### Key Features
- ✅ **Required address at signup** - No incomplete profiles
- ✅ **Order-type specific flows** - Internet orders require verification
- ✅ **Real-time WHMCS sync** - Address updates
- ✅ **Salesforce snapshots** - Point-in-time order addresses
- ✅ **Clean architecture** - Modular, maintainable code
## License ## License
[Your License Here] [Your License Here]

View File

@ -9,6 +9,7 @@ import {
SalesforceOrderItem, SalesforceOrderItem,
SalesforceQueryResult, SalesforceQueryResult,
} from "../types/salesforce-order.types"; } from "../types/salesforce-order.types";
import { getSalesforceFieldMap } from "../../common/config/field-map";
/** /**
* Main orchestrator for order operations * Main orchestrator for order operations
@ -97,8 +98,9 @@ export class OrderOrchestrator {
async getOrder(orderId: string) { async getOrder(orderId: string) {
this.logger.log({ orderId }, "Fetching order details with items"); this.logger.log({ orderId }, "Fetching order details with items");
const fields = getSalesforceFieldMap();
const orderSoql = ` const orderSoql = `
SELECT Id, OrderNumber, Status, Order_Type__c, EffectiveDate, TotalAmount, SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
Account.Name, CreatedDate, LastModifiedDate, Account.Name, CreatedDate, LastModifiedDate,
Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c, Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c,
WHMCS_Order_ID__c WHMCS_Order_ID__c
@ -158,7 +160,7 @@ export class OrderOrchestrator {
id: order.Id, id: order.Id,
orderNumber: order.OrderNumber, orderNumber: order.OrderNumber,
status: order.Status, status: order.Status,
orderType: order.Order_Type__c, orderType: order.Type,
effectiveDate: order.EffectiveDate, effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount, totalAmount: order.TotalAmount,
accountName: order.Account?.Name, accountName: order.Account?.Name,
@ -185,8 +187,9 @@ export class OrderOrchestrator {
// Get user mapping // Get user mapping
const userMapping = await this.orderValidator.validateUserMapping(userId); const userMapping = await this.orderValidator.validateUserMapping(userId);
const fields = getSalesforceFieldMap();
const ordersSoql = ` const ordersSoql = `
SELECT Id, OrderNumber, Status, Order_Type__c, EffectiveDate, TotalAmount, SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
CreatedDate, LastModifiedDate, WHMCS_Order_ID__c CreatedDate, LastModifiedDate, WHMCS_Order_ID__c
FROM Order FROM Order
WHERE AccountId = '${userMapping.sfAccountId}' WHERE AccountId = '${userMapping.sfAccountId}'
@ -248,7 +251,7 @@ export class OrderOrchestrator {
id: order.Id, id: order.Id,
orderNumber: order.OrderNumber, orderNumber: order.OrderNumber,
status: order.Status, status: order.Status,
orderType: order.Order_Type__c, orderType: order.Type,
effectiveDate: order.EffectiveDate, effectiveDate: order.EffectiveDate,
totalAmount: order.TotalAmount, totalAmount: order.TotalAmount,
createdDate: order.CreatedDate, createdDate: order.CreatedDate,

View File

@ -4,7 +4,7 @@ export interface SalesforceOrder {
Id: string; Id: string;
OrderNumber: string; OrderNumber: string;
Status: string; Status: string;
Order_Type__c: string; Type: string;
EffectiveDate: string; EffectiveDate: string;
TotalAmount: number; TotalAmount: number;
Account: { Account: {
@ -61,7 +61,7 @@ export interface OrderCreateRequest {
AccountId: string; AccountId: string;
Status: string; Status: string;
EffectiveDate: string; EffectiveDate: string;
Order_Type__c: string; Type: string;
Activation_Type__c?: string; Activation_Type__c?: string;
Activation_Scheduled_At__c?: string; Activation_Scheduled_At__c?: string;
WHMCS_Order_ID__c?: string; WHMCS_Order_ID__c?: string;

View File

@ -637,13 +637,25 @@ export class UsersService {
whmcsUpdateData.companyname = billingData.company; whmcsUpdateData.companyname = billingData.company;
} }
// Update in WHMCS // Update in WHMCS (authoritative source)
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData); await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
this.logger.log({ userId }, "Successfully updated billing information in WHMCS"); this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
} catch (error) { } catch (error) {
this.logger.error({ userId, error }, "Failed to update billing information"); this.logger.error({ userId, error }, "Failed to update billing information");
throw error;
// Provide user-friendly error message without exposing sensitive details
if (error instanceof Error && error.message.includes("403")) {
throw new Error(
"Access denied. Please contact support to update your billing information."
);
} else if (error instanceof Error && error.message.includes("404")) {
throw new Error("Account not found. Please contact support.");
} else {
throw new Error(
"Unable to update billing information. Please try again later or contact support."
);
}
} }
} }
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation"; 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";
@ -30,10 +30,39 @@ interface BillingInfo {
isComplete: boolean; isComplete: boolean;
} }
export default function BillingPage() { function BillingHeading() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const isCompletionFlow = searchParams.get("complete") === "true"; const isCompletionFlow = searchParams.get("complete") === "true";
return (
<h1 className="text-3xl font-bold text-gray-900">
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"}
</h1>
);
}
function BillingCompletionBanner() {
const searchParams = useSearchParams();
const isCompletionFlow = searchParams.get("complete") === "true";
if (!isCompletionFlow) return null;
return (
<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>
);
}
export default function BillingPage() {
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);
@ -48,7 +77,7 @@ export default function BillingPage() {
const fetchBillingInfo = async () => { const fetchBillingInfo = async () => {
try { try {
setLoading(true); setLoading(true);
const data = await authenticatedApi.get<BillingInfo>("/users/billing"); const data = await authenticatedApi.get<BillingInfo>("/me/billing");
setBillingInfo(data); setBillingInfo(data);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load billing information"); setError(err instanceof Error ? err.message : "Failed to load billing information");
@ -93,7 +122,7 @@ export default function BillingPage() {
setError(null); setError(null);
// Update address via API // Update address via API
await authenticatedApi.patch("/users/billing", { await authenticatedApi.patch("/me/billing", {
street: editedAddress.street, street: editedAddress.street,
streetLine2: editedAddress.streetLine2, streetLine2: editedAddress.streetLine2,
city: editedAddress.city, city: editedAddress.city,
@ -146,29 +175,16 @@ 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"> <Suspense
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"} fallback={<h1 className="text-3xl font-bold text-gray-900">Billing & Address</h1>}
</h1> >
<BillingHeading />
</Suspense>
</div> </div>
{isCompletionFlow && ( <Suspense fallback={null}>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6"> <BillingCompletionBanner />
<div className="flex items-start space-x-4"> </Suspense>
<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">

View File

@ -3,8 +3,44 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
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 { authenticatedApi } from "@/lib/api";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; import {
UserIcon,
PencilIcon,
CheckIcon,
XMarkIcon,
MapPinIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import countries from "world-countries";
// Type for country data
interface Country {
name: {
common: string;
};
cca2: string;
}
// Address interface
interface Address {
street: string | null;
streetLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
}
// Billing info interface
interface BillingInfo {
company: string | null;
email: string;
phone: string | null;
address: Address;
isComplete: boolean;
}
// Enhanced user type with Salesforce Account data (essential fields only) // Enhanced user type with Salesforce Account data (essential fields only)
interface EnhancedUser { interface EnhancedUser {
@ -14,52 +50,88 @@ interface EnhancedUser {
lastName?: string; lastName?: string;
company?: string; company?: string;
phone?: string; phone?: string;
billingAddress?: {
street?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
// No internal system identifiers should be exposed to clients // No internal system identifiers should be exposed to clients
// salesforceAccountId?: string; // salesforceAccountId?: string;
} }
export default function ProfilePage() { export default function ProfilePage() {
const { user } = useAuthStore(); const { user } = useAuthStore();
const enhancedUser = user as EnhancedUser | null;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isEditingAddress, setIsEditingAddress] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSavingAddress, setIsSavingAddress] = useState(false);
const [billingInfo, setBillingInfo] = useState<BillingInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: user?.firstName || "", firstName: user?.firstName || "",
lastName: user?.lastName || "", lastName: user?.lastName || "",
email: user?.email || "", email: user?.email || "",
company: enhancedUser?.company || user?.company || "",
phone: user?.phone || "", phone: user?.phone || "",
}); });
const [addressData, setAddressData] = useState<Address>({
street: "",
streetLine2: "",
city: "",
state: "",
postalCode: "",
country: "",
});
// Fetch billing info on component mount
useEffect(() => {
const fetchBillingInfo = async () => {
try {
setLoading(true);
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
setBillingInfo(data);
setAddressData(data.address);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load address information");
} finally {
setLoading(false);
}
};
void fetchBillingInfo();
}, []);
const handleEdit = () => { const handleEdit = () => {
setIsEditing(true); setIsEditing(true);
setFormData({ setFormData({
firstName: user?.firstName || "", firstName: user?.firstName || "",
lastName: user?.lastName || "", lastName: user?.lastName || "",
email: user?.email || "", email: user?.email || "",
company: enhancedUser?.company || user?.company || "",
phone: user?.phone || "", phone: user?.phone || "",
}); });
}; };
const handleEditAddress = () => {
setIsEditingAddress(true);
if (billingInfo?.address) {
setAddressData(billingInfo.address);
}
};
const handleCancel = () => { const handleCancel = () => {
setIsEditing(false); setIsEditing(false);
setFormData({ setFormData({
firstName: user?.firstName || "", firstName: user?.firstName || "",
lastName: user?.lastName || "", lastName: user?.lastName || "",
email: user?.email || "", email: user?.email || "",
company: enhancedUser?.company || user?.company || "",
phone: user?.phone || "", phone: user?.phone || "",
}); });
}; };
const handleCancelAddress = () => {
setIsEditingAddress(false);
if (billingInfo?.address) {
setAddressData(billingInfo.address);
}
};
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
@ -79,7 +151,6 @@ export default function ProfilePage() {
body: JSON.stringify({ body: JSON.stringify({
firstName: formData.firstName, firstName: formData.firstName,
lastName: formData.lastName, lastName: formData.lastName,
company: formData.company,
phone: formData.phone, phone: formData.phone,
}), }),
}); });
@ -105,6 +176,52 @@ export default function ProfilePage() {
} }
}; };
const handleSaveAddress = async () => {
setIsSavingAddress(true);
setError(null);
try {
// Validate required fields
const isComplete = !!(
addressData.street?.trim() &&
addressData.city?.trim() &&
addressData.state?.trim() &&
addressData.postalCode?.trim() &&
addressData.country?.trim()
);
if (!isComplete) {
setError("Please fill in all required address fields");
return;
}
await authenticatedApi.patch("/me/billing", {
street: addressData.street,
streetLine2: addressData.streetLine2,
city: addressData.city,
state: addressData.state,
postalCode: addressData.postalCode,
country: addressData.country,
});
// Update local state
if (billingInfo) {
setBillingInfo({
...billingInfo,
address: addressData,
isComplete: true,
});
}
setIsEditingAddress(false);
} catch (error) {
logger.error("Error updating address:", error);
setError(error instanceof Error ? error.message : "Failed to update address");
} finally {
setIsSavingAddress(false);
}
};
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@ -112,6 +229,14 @@ export default function ProfilePage() {
})); }));
}; };
const handleAddressChange = (field: keyof Address, value: string) => {
setError(null); // Clear error on input
setAddressData(prev => ({
...prev,
[field]: value,
}));
};
// Update form data when user data changes (e.g., when Salesforce data loads) // Update form data when user data changes (e.g., when Salesforce data loads)
useEffect(() => { useEffect(() => {
if (user && !isEditing) { if (user && !isEditing) {
@ -119,11 +244,25 @@ export default function ProfilePage() {
firstName: user.firstName || "", firstName: user.firstName || "",
lastName: user.lastName || "", lastName: user.lastName || "",
email: user.email || "", email: user.email || "",
company: enhancedUser?.company || user.company || "",
phone: user.phone || "", phone: user.phone || "",
}); });
} }
}, [user, enhancedUser?.company, isEditing]); }, [user, isEditing]);
if (loading) {
return (
<DashboardLayout>
<div className="py-6">
<div className="max-w-4xl mx-auto px-4 sm:px-6 md:px-8">
<div className="flex items-center justify-center py-12">
<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 profile...</span>
</div>
</div>
</div>
</DashboardLayout>
);
}
return ( return (
<DashboardLayout> <DashboardLayout>
@ -132,42 +271,55 @@ export default function ProfilePage() {
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full flex items-center justify-center shadow-lg">
<span className="text-2xl font-bold text-white"> <span className="text-2xl font-bold text-white">
{user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "U"} {user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "U"}
</span> </span>
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">
{user?.firstName && user?.lastName {user?.firstName && user?.lastName
? `${user.firstName} ${user.lastName}` ? `${user.firstName} ${user.lastName}`
: user?.firstName : user?.firstName
? user.firstName ? user.firstName
: user?.email || "User Profile"} : "Profile"}
</h1> </h1>
<p className="mt-1 text-sm text-gray-600">{user?.email}</p> <p className="mt-1 text-lg text-gray-600">{user?.email}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Manage your account information and preferences Manage your personal information and address
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-6"> {/* Error Banner */}
{/* Profile Information */} {error && (
<div className="bg-white shadow rounded-lg"> <div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4">
<div className="px-6 py-4 border-b border-gray-200"> <div className="flex items-start space-x-3">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mt-0.5 flex-shrink-0" />
<div>
<h3 className="text-sm font-medium text-red-800">Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
</div>
</div>
</div>
)}
<div className="space-y-8">
{/* Personal Information */}
<div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-5 border-b border-gray-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<UserIcon className="h-5 w-5 text-gray-400" /> <UserIcon className="h-6 w-6 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">Personal Information</h3> <h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
</div> </div>
{!isEditing && ( {!isEditing && (
<button <button
onClick={handleEdit} onClick={handleEdit}
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors"
> >
<PencilIcon className="h-4 w-4 mr-1" /> <PencilIcon className="h-4 w-4 mr-2" />
Edit Edit
</button> </button>
)} )}
@ -175,7 +327,7 @@ export default function ProfilePage() {
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{/* First Name */} {/* First Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
@ -186,7 +338,7 @@ export default function ProfilePage() {
type="text" type="text"
value={formData.firstName} value={formData.firstName}
onChange={e => handleInputChange("firstName", e.target.value)} onChange={e => handleInputChange("firstName", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/> />
) : ( ) : (
<p className="text-sm text-gray-900 py-2"> <p className="text-sm text-gray-900 py-2">
@ -207,7 +359,7 @@ export default function ProfilePage() {
type="text" type="text"
value={formData.lastName} value={formData.lastName}
onChange={e => handleInputChange("lastName", e.target.value)} onChange={e => handleInputChange("lastName", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/> />
) : ( ) : (
<p className="text-sm text-gray-900 py-2"> <p className="text-sm text-gray-900 py-2">
@ -220,39 +372,28 @@ export default function ProfilePage() {
{/* Email */} {/* Email */}
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-3">
Email Address Email Address
</label> </label>
<div className="flex items-center space-x-2"> <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-900 py-2 flex-1">{user?.email}</p> <div className="flex items-center justify-between">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <p className="text-base text-gray-900 font-medium">{user?.email}</p>
Verified <span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
</span> <svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
</div> <path
<p className="text-xs text-gray-500 mt-1"> fillRule="evenodd"
Email cannot be changed. Contact support if you need to update your email d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
address. clipRule="evenodd"
</p> />
</div> </svg>
Verified
{/* Company */} </span>
<div> </div>
<label className="block text-sm font-medium text-gray-700 mb-2">Company</label> <p className="text-xs text-gray-500 mt-2">
{isEditing ? ( Email cannot be changed. Contact support if you need to update your email
<input address.
type="text"
value={formData.company}
onChange={e => handleInputChange("company", e.target.value)}
placeholder="Your company name"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
) : (
<p className="text-sm text-gray-900 py-2">
{enhancedUser?.company || user?.company || (
<span className="text-gray-500 italic">Not provided</span>
)}
</p> </p>
)} </div>
</div> </div>
{/* Phone */} {/* Phone */}
@ -266,7 +407,7 @@ export default function ProfilePage() {
value={formData.phone} value={formData.phone}
onChange={e => handleInputChange("phone", e.target.value)} onChange={e => handleInputChange("phone", e.target.value)}
placeholder="+81 XX-XXXX-XXXX" placeholder="+81 XX-XXXX-XXXX"
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" className="block w-full px-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/> />
) : ( ) : (
<p className="text-sm text-gray-900 py-2"> <p className="text-sm text-gray-900 py-2">
@ -311,32 +452,213 @@ export default function ProfilePage() {
</div> </div>
</div> </div>
{/* Account Status */} {/* Address Information */}
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow-sm rounded-xl border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-5 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Account Status</h3> <div className="flex items-center justify-between">
</div> <div className="flex items-center space-x-3">
<div className="p-6"> <MapPinIcon className="h-6 w-6 text-blue-600" />
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
<div>
<dt className="text-sm font-medium text-gray-500">Account Type</dt>
<dd className="text-sm text-gray-900 mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Customer Account
</span>
</dd>
</div> </div>
<div> {!isEditingAddress && (
<dt className="text-sm font-medium text-gray-500">Account Status</dt> <button
<dd className="text-sm text-gray-900 mt-1"> onClick={handleEditAddress}
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 transition-colors"
Active >
</span> <PencilIcon className="h-4 w-4 mr-2" />
</dd> Edit
</div> </button>
{/* Internal data sources are not shown to users */} )}
</div> </div>
</div> </div>
<div className="p-6">
{isEditingAddress ? (
<div className="space-y-6">
{/* Street Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Street Address *
</label>
<input
type="text"
value={addressData.street || ""}
onChange={e => handleAddressChange("street", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="123 Main Street"
required
/>
</div>
{/* Street Address Line 2 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Street Address Line 2
</label>
<input
type="text"
value={addressData.streetLine2 || ""}
onChange={e => handleAddressChange("streetLine2", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Apartment, suite, etc. (optional)"
/>
</div>
{/* City, State, Postal Code */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
City *
</label>
<input
type="text"
value={addressData.city || ""}
onChange={e => handleAddressChange("city", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Tokyo"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
State/Prefecture *
</label>
<input
type="text"
value={addressData.state || ""}
onChange={e => handleAddressChange("state", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Tokyo"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Postal Code *
</label>
<input
type="text"
value={addressData.postalCode || ""}
onChange={e => handleAddressChange("postalCode", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="100-0001"
required
/>
</div>
</div>
{/* Country */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Country *
</label>
<select
value={addressData.country || ""}
onChange={e => handleAddressChange("country", e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="">Select Country</option>
{(countries as Country[])
.sort((a: Country, b: Country) =>
a.name.common.localeCompare(b.name.common)
)
.map((country: Country) => (
<option key={country.cca2} value={country.cca2}>
{country.name.common}
</option>
))}
</select>
</div>
{/* Address Edit Actions */}
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-200">
<button
onClick={handleCancelAddress}
disabled={isSavingAddress}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
<XMarkIcon className="h-4 w-4 mr-2" />
Cancel
</button>
<button
onClick={() => {
void handleSaveAddress();
}}
disabled={isSavingAddress}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isSavingAddress ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<CheckIcon className="h-4 w-4 mr-2" />
Save Address
</>
)}
</button>
</div>
</div>
) : (
<div>
{billingInfo?.address &&
(billingInfo.address.street || billingInfo.address.city) ? (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
<div className="flex items-start space-x-3">
<MapPinIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-gray-900 space-y-1">
{billingInfo.address.street && (
<p className="font-semibold text-gray-900">
{billingInfo.address.street}
</p>
)}
{billingInfo.address.streetLine2 && (
<p className="text-gray-700">{billingInfo.address.streetLine2}</p>
)}
{(billingInfo.address.city ||
billingInfo.address.state ||
billingInfo.address.postalCode) && (
<p className="text-gray-700">
{[
billingInfo.address.city,
billingInfo.address.state,
billingInfo.address.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
)}
{billingInfo.address.country && (
<p className="text-gray-600 font-medium">
{(countries as Country[]).find(
(c: Country) => c.cca2 === billingInfo.address.country
)?.name.common || billingInfo.address.country}
</p>
)}
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No address on file</p>
<button
onClick={handleEditAddress}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-white bg-blue-600 hover:bg-blue-700 transition-colors"
>
<MapPinIcon className="h-4 w-4 mr-2" />
Add Address
</button>
</div>
)}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -21,6 +21,15 @@ interface OrderItem {
}; };
} }
interface StatusInfo {
label: string;
color: string;
bgColor: string;
description: string;
nextAction?: string;
timeline?: string;
}
interface OrderSummary { interface OrderSummary {
id: string; id: string;
orderNumber?: string; orderNumber?: string;
@ -38,6 +47,100 @@ interface OrderSummary {
items?: OrderItem[]; items?: OrderItem[];
} }
const getDetailedStatusInfo = (
status: string,
activationStatus?: string,
activationType?: string,
scheduledAt?: string
): StatusInfo => {
if (status === "Activated") {
return {
label: "Service Active",
color: "text-green-800",
bgColor: "bg-green-50 border-green-200",
description: "Your service is active and ready to use",
timeline: "Service activated successfully",
};
}
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
color: "text-blue-800",
bgColor: "bg-blue-50 border-blue-200",
description: "Our team is reviewing your order details",
nextAction: "We&apos;ll contact you within 1-2 business days with next steps",
timeline: "Review typically takes 1-2 business days",
};
}
if (activationStatus === "Scheduled") {
const scheduledDate = scheduledAt
? new Date(scheduledAt).toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
})
: "soon";
return {
label: "Installation Scheduled",
color: "text-orange-800",
bgColor: "bg-orange-50 border-orange-200",
description: "Your installation has been scheduled",
nextAction: `Installation scheduled for ${scheduledDate}`,
timeline: "Please be available during the scheduled time",
};
}
if (activationStatus === "In Progress") {
return {
label: "Setting Up Service",
color: "text-purple-800",
bgColor: "bg-purple-50 border-purple-200",
description: "We&apos;re configuring your service",
nextAction: "Installation team will contact you to schedule",
timeline: "Setup typically takes 3-5 business days",
};
}
return {
label: status || "Processing",
color: "text-gray-800",
bgColor: "bg-gray-50 border-gray-200",
description: "Your order is being processed",
timeline: "We&apos;ll update you as progress is made",
};
};
const getServiceTypeIcon = (orderType?: string) => {
switch (orderType) {
case "Internet":
return "🌐";
case "SIM":
return "📱";
case "VPN":
return "🔒";
default:
return "📦";
}
};
const calculateDetailedTotals = (items: OrderItem[]) => {
let monthlyTotal = 0;
let oneTimeTotal = 0;
items.forEach(item => {
if (item.product.billingCycle === "Monthly") {
monthlyTotal += item.totalPrice || 0;
} else {
oneTimeTotal += item.totalPrice || 0;
}
});
return { monthlyTotal, oneTimeTotal };
};
export default function OrderStatusPage() { export default function OrderStatusPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -68,8 +171,10 @@ export default function OrderStatusPage() {
return ( return (
<PageLayout <PageLayout
icon={<ClipboardDocumentCheckIcon />} icon={<ClipboardDocumentCheckIcon />}
title={`Order ${data?.orderNumber || params.id}`} title={data ? `${data.orderType} Service Order` : "Order Details"}
description="We'll update this page as your order progresses" description={
data ? `Order #${data.orderNumber || data.id.slice(-8)}` : "Loading order details..."
}
> >
{error && <div className="text-red-600 text-sm mb-4">{error}</div>} {error && <div className="text-red-600 text-sm mb-4">{error}</div>}
@ -102,104 +207,321 @@ export default function OrderStatusPage() {
</div> </div>
)} )}
{/* Order Header Information */} {/* Service Overview */}
<div className="bg-white border rounded-xl p-6 mb-6"> {data &&
<h2 className="text-xl font-semibold text-gray-900 mb-4">Order Details</h2> (() => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> const statusInfo = getDetailedStatusInfo(
<div> data.status,
<span className="font-medium text-gray-700">Order Status:</span>{" "} data.activationStatus,
<span data.activationType,
className={`px-2 py-1 rounded-full text-sm ${ data.scheduledAt
data?.status === "Draft" );
? "bg-yellow-100 text-yellow-800" const serviceIcon = getServiceTypeIcon(data.orderType);
: data?.status === "Activated"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{data?.status || "Loading..."}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Order Type:</span> {data?.orderType || "-"}
</div>
<div>
<span className="font-medium text-gray-700">Activation Status:</span>{" "}
{data?.activationStatus || "-"}
</div>
<div>
<span className="font-medium text-gray-700">Activation Type:</span>{" "}
{data?.activationType || "-"}
</div>
<div>
<span className="font-medium text-gray-700">Scheduled At:</span>{" "}
{data?.scheduledAt || "-"}
</div>
<div>
<span className="font-medium text-gray-700">WHMCS Order ID:</span>{" "}
{data?.whmcsOrderId || "-"}
</div>
{data?.totalAmount && (
<div>
<span className="font-medium text-gray-700">Total Amount:</span> ¥
{data.totalAmount.toLocaleString()}
</div>
)}
<div>
<span className="font-medium text-gray-700">Created:</span>{" "}
{data?.createdDate ? new Date(data.createdDate).toLocaleDateString() : "-"}
</div>
</div>
</div>
{/* Order Items */} return (
{data?.items && data.items.length > 0 && ( <div className="bg-white border rounded-2xl p-8 mb-8">
<div className="bg-white border rounded-xl p-6"> {/* Service Header */}
<h2 className="text-xl font-semibold text-gray-900 mb-4">Order Items</h2> <div className="flex items-start gap-6 mb-6">
<div className="space-y-4"> <div className="text-4xl">{serviceIcon}</div>
{data.items.map(item => ( <div className="flex-1">
<div key={item.id} className="border border-gray-200 rounded-lg p-4"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
<div className="flex justify-between items-start"> {data.orderType} Service
<div className="flex-1"> </h2>
<h3 className="font-medium text-gray-900">{item.product.name}</h3> <p className="text-gray-600 mb-4">
<div className="text-sm text-gray-600 space-y-1 mt-1"> Order #{data.orderNumber || data.id.slice(-8)} Placed{" "}
<div> {new Date(data.createdDate).toLocaleDateString("en-US", {
<span className="font-medium">SKU:</span> {item.product.sku} weekday: "long",
</div> month: "long",
<div> day: "numeric",
<span className="font-medium">Item Class:</span> {item.product.itemClass} year: "numeric",
</div> })}
<div> </p>
<span className="font-medium">Billing Cycle:</span>{" "} </div>
{item.product.billingCycle}
</div> {data.items &&
{item.product.whmcsProductId && ( data.items.length > 0 &&
<div> (() => {
<span className="font-medium">WHMCS Product ID:</span>{" "} const totals = calculateDetailedTotals(data.items);
{item.product.whmcsProductId}
return (
<div className="text-right">
<div className="space-y-2">
{totals.monthlyTotal > 0 && (
<div>
<p className="text-3xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()}
</p>
<p className="text-gray-500">per month</p>
</div>
)}
{totals.oneTimeTotal > 0 && (
<div className="mt-2">
<p className="text-2xl font-bold text-orange-600">
¥{totals.oneTimeTotal.toLocaleString()}
</p>
<p className="text-gray-500">one-time</p>
</div>
)}
{/* Fallback to TotalAmount if no items or calculation fails */}
{totals.monthlyTotal === 0 &&
totals.oneTimeTotal === 0 &&
data.totalAmount && (
<div>
<p className="text-3xl font-bold text-gray-900">
¥{data.totalAmount.toLocaleString()}
</p>
<p className="text-gray-500">total amount</p>
</div>
)}
</div> </div>
)} </div>
</div> );
})()}
</div>
{/* Status Card */}
<div className={`border-2 rounded-xl p-6 ${statusInfo.bgColor}`}>
<div className="flex items-start gap-4">
<div
className={`w-12 h-12 rounded-full ${statusInfo.bgColor.replace("bg-", "bg-").replace("-50", "-100")} flex items-center justify-center`}
>
{data.status === "Activated" ? (
<svg
className="w-6 h-6 text-green-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
) : (
<svg
className={`w-6 h-6 ${statusInfo.color}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
</div> </div>
<div className="text-right ml-4">
<div className="font-medium text-gray-900">Qty: {item.quantity}</div> <div className="flex-1">
{item.unitPrice && ( <h3 className={`text-xl font-bold ${statusInfo.color} mb-2`}>
<div className="text-sm text-gray-600"> {statusInfo.label}
¥{item.unitPrice.toLocaleString()} each </h3>
<p className="text-gray-700 mb-2">{statusInfo.description}</p>
{statusInfo.nextAction && (
<div className="bg-white bg-opacity-60 rounded-lg p-3 mb-3">
<p className="font-medium text-gray-900 text-sm">Next Steps:</p>
<p className="text-gray-700 text-sm">{statusInfo.nextAction}</p>
</div> </div>
)} )}
{item.totalPrice && (
<div className="font-semibold text-lg text-gray-900"> {statusInfo.timeline && (
¥{item.totalPrice.toLocaleString()} <p className="text-sm text-gray-600">
</div> <span className="font-medium">Timeline:</span> {statusInfo.timeline}
</p>
)} )}
</div> </div>
</div> </div>
</div> </div>
))} </div>
);
})()}
{/* Service Details */}
{data?.items && data.items.length > 0 && (
<div className="bg-white border rounded-xl p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Your Services & Products</h2>
<div className="space-y-3">
{data.items.map(item => {
// Use the actual Item_Class__c values from Salesforce documentation
const itemClass = item.product.itemClass;
// Get appropriate icon and color based on actual item class
const getItemTypeInfo = () => {
switch (itemClass) {
case "Service":
return {
icon: "⭐",
bg: "bg-blue-50 border-blue-200",
iconBg: "bg-blue-100 text-blue-600",
label: "Service",
labelColor: "text-blue-600",
};
case "Installation":
return {
icon: "🔧",
bg: "bg-orange-50 border-orange-200",
iconBg: "bg-orange-100 text-orange-600",
label: "Installation",
labelColor: "text-orange-600",
};
case "Add-on":
return {
icon: "+",
bg: "bg-green-50 border-green-200",
iconBg: "bg-green-100 text-green-600",
label: "Add-on",
labelColor: "text-green-600",
};
case "Activation":
return {
icon: "⚡",
bg: "bg-purple-50 border-purple-200",
iconBg: "bg-purple-100 text-purple-600",
label: "Activation",
labelColor: "text-purple-600",
};
default:
return {
icon: "📦",
bg: "bg-gray-50 border-gray-200",
iconBg: "bg-gray-100 text-gray-600",
label: itemClass || "Other",
labelColor: "text-gray-600",
};
}
};
const typeInfo = getItemTypeInfo();
return (
<div key={item.id} className={`rounded-lg p-4 border ${typeInfo.bg}`}>
<div className="flex justify-between items-start">
<div className="flex items-start gap-3 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm ${typeInfo.iconBg} flex-shrink-0`}
>
{typeInfo.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-gray-900 truncate">
{item.product.name}
</h3>
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${typeInfo.bg} ${typeInfo.labelColor} flex-shrink-0`}
>
{typeInfo.label}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
<span className="font-medium">{item.product.billingCycle}</span>
{item.quantity > 1 && <span>Qty: {item.quantity}</span>}
{item.product.itemClass && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded">
{item.product.itemClass}
</span>
)}
</div>
</div>
</div>
<div className="text-right ml-3 flex-shrink-0">
{item.totalPrice && (
<div className="font-semibold text-gray-900">
¥{item.totalPrice.toLocaleString()}
</div>
)}
<div className="text-xs text-gray-500">
{item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
</div>
</div>
</div>
</div>
);
})}
</div> </div>
</div> </div>
)} )}
{/* Pricing Summary */}
{data?.items &&
data.items.length > 0 &&
(() => {
const totals = calculateDetailedTotals(data.items);
return (
<div className="bg-white border rounded-xl p-6 mb-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Pricing Summary</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{totals.monthlyTotal > 0 && (
<div className="bg-blue-50 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-blue-600">
¥{totals.monthlyTotal.toLocaleString()}
</p>
<p className="text-sm text-gray-600">Monthly Charges</p>
</div>
)}
{totals.oneTimeTotal > 0 && (
<div className="bg-orange-50 rounded-lg p-4 text-center">
<p className="text-2xl font-bold text-orange-600">
¥{totals.oneTimeTotal.toLocaleString()}
</p>
<p className="text-sm text-gray-600">One-time Charges</p>
</div>
)}
</div>
{/* Compact Fee Disclaimer */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 text-sm"></span>
<div>
<p className="text-sm font-medium text-yellow-900">Additional fees may apply</p>
<p className="text-xs text-yellow-800 mt-1">
Weekend installation (+¥3,000), express setup, or special configuration
charges may be added. We&apos;ll contact you before applying any additional
fees.
</p>
</div>
</div>
</div>
</div>
);
})()}
{/* Support Contact */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-900">Need Help?</h3>
<p className="text-blue-800 text-sm">
Questions about your order? Contact our support team.
</p>
</div>
<div className="flex gap-2">
<a
href="mailto:support@example.com"
className="bg-blue-600 text-white px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
📧 Email
</a>
<a
href="tel:+1234567890"
className="bg-white text-blue-600 border border-blue-600 px-3 py-2 rounded-lg text-sm font-medium hover:bg-blue-50 transition-colors"
>
📞 Call
</a>
</div>
</div>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

@ -24,6 +24,14 @@ interface OrderSummary {
}>; }>;
} }
interface StatusInfo {
label: string;
color: string;
bgColor: string;
description: string;
nextAction?: string;
}
function OrdersSuccessBanner() { function OrdersSuccessBanner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const showSuccess = searchParams.get("status") === "success"; const showSuccess = searchParams.get("status") === "success";
@ -66,17 +74,81 @@ export default function OrdersPage() {
void fetchOrders(); void fetchOrders();
}, []); }, []);
const getStatusColor = (status: string) => { const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {
switch (status) { // Combine order status and activation status for better UX
case "Draft": if (status === "Activated") {
return "bg-yellow-100 text-yellow-800"; return {
case "Activated": label: "Active",
return "bg-green-100 text-green-800"; color: "text-green-800",
case "Pending Review": bgColor: "bg-green-100",
return "bg-blue-100 text-blue-800"; description: "Your service is active and ready to use",
default: };
return "bg-gray-100 text-gray-800";
} }
if (status === "Draft" || status === "Pending Review") {
return {
label: "Under Review",
color: "text-blue-800",
bgColor: "bg-blue-100",
description: "We're reviewing your order",
nextAction: "We'll contact you within 1-2 business days",
};
}
if (activationStatus === "Scheduled" || activationStatus === "In Progress") {
return {
label: "Setting Up",
color: "text-orange-800",
bgColor: "bg-orange-100",
description: "We're preparing your service",
nextAction: "Installation will be scheduled soon",
};
}
return {
label: status || "Processing",
color: "text-gray-800",
bgColor: "bg-gray-100",
description: "Order is being processed",
};
};
const getServiceTypeDisplay = (orderType?: string) => {
switch (orderType) {
case "Internet":
return { icon: "🌐", label: "Internet Service" };
case "SIM":
return { icon: "📱", label: "Mobile Service" };
case "VPN":
return { icon: "🔒", label: "VPN Service" };
default:
return { icon: "📦", label: "Service" };
}
};
const getServiceSummary = (order: OrderSummary) => {
if (order.itemsSummary && order.itemsSummary.length > 0) {
const mainItem = order.itemsSummary[0];
const additionalCount = order.itemsSummary.length - 1;
let summary = mainItem.name || "Service";
if (additionalCount > 0) {
summary += ` +${additionalCount} more`;
}
return summary;
}
return order.itemSummary || "Service package";
};
const calculateOrderTotals = (order: OrderSummary) => {
// For now, we only have TotalAmount from Salesforce
// In a future enhancement, we could fetch individual item details to separate monthly vs one-time
// For now, we'll assume TotalAmount is monthly unless we have specific indicators
return {
monthlyTotal: order.totalAmount || 0,
oneTimeTotal: 0, // Will be calculated when we have item-level billing cycle data
};
}; };
return ( return (
@ -114,56 +186,112 @@ export default function OrdersPage() {
</button> </button>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-6">
{orders.map(order => ( {orders.map(order => {
<div const statusInfo = getStatusInfo(order.status, order.activationStatus);
key={order.id} const serviceType = getServiceTypeDisplay(order.orderType);
className="bg-white border rounded-xl p-6 hover:shadow-md transition-shadow cursor-pointer" const serviceSummary = getServiceSummary(order);
onClick={() => router.push(`/orders/${order.id}`)}
>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Order {order.orderNumber || order.id.slice(-8)}
</h3>
<p className="text-sm text-gray-600">
{order.orderType} {new Date(order.createdDate).toLocaleDateString()}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(order.status)}`}
>
{order.status}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm"> return (
<div> <div
<span className="font-medium text-gray-700">Items:</span> key={order.id}
<p className="text-gray-600"> className="bg-white border border-gray-200 rounded-2xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 cursor-pointer group"
{order.itemSummary || onClick={() => router.push(`/orders/${order.id}`)}
(order.itemsSummary && order.itemsSummary.length > 0 >
? order.itemsSummary {/* Header */}
.map(item => `${item.name} (${item.quantity})`) <div className="flex justify-between items-start mb-4">
.join(", ") <div className="flex items-start gap-4">
: "No items")} <div className="text-2xl">{serviceType.icon}</div>
</p> <div>
</div> <h3 className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
<div> {serviceType.label}
<span className="font-medium text-gray-700">Activation Status:</span> </h3>
<p className="text-gray-600">{order.activationStatus || "Not Started"}</p> <p className="text-sm text-gray-500 mt-1">
</div> Order #{order.orderNumber || order.id.slice(-8)} {" "}
{order.totalAmount && ( {new Date(order.createdDate).toLocaleDateString("en-US", {
<div> month: "short",
<span className="font-medium text-gray-700">Total:</span> day: "numeric",
<p className="text-gray-900 font-semibold"> year: "numeric",
¥{order.totalAmount.toLocaleString()} })}
</p> </p>
</div>
</div> </div>
)}
<div className="text-right">
<span
className={`px-4 py-2 rounded-full text-sm font-semibold ${statusInfo.bgColor} ${statusInfo.color}`}
>
{statusInfo.label}
</span>
</div>
</div>
{/* Service Details */}
<div className="bg-gray-50 rounded-xl p-4 mb-4">
<div className="flex justify-between items-center">
<div>
<p className="font-medium text-gray-900">{serviceSummary}</p>
<p className="text-sm text-gray-600 mt-1">{statusInfo.description}</p>
{statusInfo.nextAction && (
<p className="text-sm text-blue-600 mt-1 font-medium">
{statusInfo.nextAction}
</p>
)}
</div>
{order.totalAmount &&
(() => {
const totals = calculateOrderTotals(order);
return (
<div className="text-right">
<div className="space-y-1">
<p className="text-2xl font-bold text-gray-900">
¥{totals.monthlyTotal.toLocaleString()}
</p>
<p className="text-sm text-gray-500">per month</p>
{totals.oneTimeTotal > 0 && (
<>
<p className="text-lg font-semibold text-orange-600">
¥{totals.oneTimeTotal.toLocaleString()}
</p>
<p className="text-xs text-gray-500">one-time</p>
</>
)}
</div>
{/* Fee Disclaimer */}
<div className="mt-3 text-xs text-gray-500 text-left">
<p>* Additional fees may apply</p>
<p className="text-gray-400">(e.g., weekend installation)</p>
</div>
</div>
);
})()}
</div>
</div>
{/* Action Indicator */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Click to view details</span>
<svg
className="w-5 h-5 text-gray-400 group-hover:text-blue-500 transition-colors"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</PageLayout> </PageLayout>

View File

@ -51,7 +51,7 @@ export function AddressConfirmation({
const fetchBillingInfo = useCallback(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>("/me/billing");
setBillingInfo(data); setBillingInfo(data);
// Since address is required at signup, it should always be complete // Since address is required at signup, it should always be complete
@ -208,8 +208,8 @@ export function AddressConfirmation({
<strong>Internet Installation Address Verification Required</strong> <strong>Internet Installation Address Verification Required</strong>
</p> </p>
<p className="text-sm text-blue-700 mt-1"> <p className="text-sm text-blue-700 mt-1">
Please verify this is the correct address for your internet installation. Please verify this is the correct address for your internet installation. A
A technician will visit this location for setup. technician will visit this location for setup.
</p> </p>
</div> </div>
</div> </div>

View File

@ -42,7 +42,6 @@ const navigation = [
name: "Billing", name: "Billing",
icon: CreditCardIcon, icon: CreditCardIcon,
children: [ children: [
{ name: "Address & Info", href: "/account/billing" },
{ name: "Invoices", href: "/billing/invoices" }, { name: "Invoices", href: "/billing/invoices" },
{ name: "Payment Methods", href: "/billing/payments" }, { name: "Payment Methods", href: "/billing/payments" },
], ],

View File

@ -35,7 +35,7 @@ export function useProfileCompletion(): ProfileCompletionStatus {
useEffect(() => { useEffect(() => {
const checkProfileCompletion = async () => { const checkProfileCompletion = async () => {
try { try {
const billingInfo = await authenticatedApi.get<BillingInfo>("/users/billing"); const billingInfo = await authenticatedApi.get<BillingInfo>("/me/billing");
setIsComplete(billingInfo.isComplete); setIsComplete(billingInfo.isComplete);
} catch (error) { } catch (error) {
console.error("Failed to check profile completion:", error); console.error("Failed to check profile completion:", error);

View File

@ -0,0 +1,15 @@
declare module "world-countries" {
interface Country {
name: {
common: string;
official: string;
};
cca2: string;
cca3: string;
ccn3: string;
cioc: string;
}
const countries: Country[];
export default countries;
}

351
docs/ADDRESS_SYSTEM.md Normal file
View File

@ -0,0 +1,351 @@
# Address System Documentation
## Overview
Our address system follows a **clean, logical flow** where address is **required at signup** and managed throughout the user lifecycle. This eliminates surprises during checkout and ensures data integrity.
## 🎯 Core Logic
```
Registration → Required Address → WHMCS Storage → Order Snapshots
```
## Architecture
```mermaid
graph TD
A[User Registration] --> B[Required Address Validation]
B --> C[WHMCS Client Creation]
C --> D[Complete Profile]
D --> E[Checkout Flow]
E --> F{Order Type?}
F -->|Internet| G[Explicit Address Verification]
F -->|Other| H[Auto-Confirm Address]
G --> I[Address Snapshot]
H --> I
I --> J[Salesforce Order]
D --> K[Profile Management]
K --> L[Address Updates]
L --> M[WHMCS Sync]
```
## Key Components
### Backend
- `SignupDto` - Address required at registration
- `UsersService.updateBillingInfo()` - WHMCS sync
- `OrderBuilder.addAddressSnapshot()` - Salesforce snapshots
### Frontend
- `AddressConfirmation` - Checkout verification
- `ProfileCompletionGuard` - Handles incomplete profiles
- `/account/billing` - Address management
## Order Type Flows
| Order Type | Address Behavior |
|------------|------------------|
| **Internet** | ✅ Explicit verification required (technician visit) |
| **SIM/VPN/Other** | ✅ Auto-confirm (exists from signup) |
## Implementation
### 1. Signup Flow (Required Address)
#### Backend Validation
```typescript
// apps/bff/src/auth/dto/signup.dto.ts
export class AddressDto {
@IsNotEmpty() line1: string; // Required
@IsOptional() line2?: string; // Optional
@IsNotEmpty() city: string; // Required
@IsNotEmpty() state: string; // Required
@IsNotEmpty() postalCode: string; // Required
@IsNotEmpty() country: string; // Required
}
export class SignupDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto; // ✅ REQUIRED - not optional
}
```
#### WHMCS Client Creation
```typescript
// apps/bff/src/auth/auth.service.ts
await this.whmcsService.addClient({
address1: address.line1, // Required field
address2: address.line2 || "", // Optional field
city: address.city, // Required field
state: address.state, // Required field
postcode: address.postalCode, // Required field
country: address.country, // Required field
});
```
### 2. Checkout Flow
#### Address Confirmation Logic
```typescript
// apps/portal/src/components/checkout/address-confirmation.tsx
// Since address is required at signup, it should always be complete
if (requiresAddressVerification) {
// Internet orders: require explicit verification
setAddressConfirmed(false);
onAddressIncomplete(); // Keep disabled until confirmed
} else {
// Other orders: auto-confirm since address exists from signup
onAddressConfirmed(data.address);
setAddressConfirmed(true);
}
```
#### Internet Order Verification
```typescript
{isInternetOrder && !addressConfirmed && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-800">
<strong>Internet Installation Address Verification Required</strong>
</p>
<p className="text-sm text-blue-700 mt-1">
Please verify this is the correct address for your internet installation.
A technician will visit this location for setup.
</p>
</div>
)}
```
### 3. Address Updates & WHMCS Sync
```typescript
// apps/bff/src/users/users.service.ts
async updateBillingInfo(userId: string, billingData: UpdateBillingDto): Promise<void> {
const mapping = await this.mappingsService.findByUserId(userId);
// Prepare WHMCS update data
const whmcsUpdateData = {
address1: billingData.street,
address2: billingData.streetLine2,
city: billingData.city,
state: billingData.state,
postcode: billingData.postalCode,
country: billingData.country,
};
// Update in WHMCS (authoritative source)
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
}
```
### 4. Order Address Snapshots
```typescript
// apps/bff/src/orders/services/order-builder.service.ts
private async addAddressSnapshot(orderFields: Record<string, unknown>, userId: string, body: CreateOrderBody): Promise<void> {
const billingInfo = await this.usersService.getBillingInfo(userId);
const orderAddress = (body.configurations as any)?.address;
const addressChanged = !!(orderAddress);
const addressToUse = orderAddress || billingInfo.address;
// Combine street lines for Salesforce
const fullStreet = [addressToUse?.street, addressToUse?.streetLine2]
.filter(Boolean)
.join(", ");
// Always populate billing address fields
orderFields["BillToStreet"] = fullStreet || "";
orderFields["BillToCity"] = addressToUse?.city || "";
orderFields["BillToState"] = addressToUse?.state || "";
orderFields["BillToPostalCode"] = addressToUse?.postalCode || "";
orderFields["BillToCountry"] = addressToUse?.country || "";
// Set change flag
orderFields["Address_Changed__c"] = addressChanged;
}
```
## Field Mapping
### WHMCS ↔ Internal ↔ Salesforce
```
WHMCS → Internal → Salesforce Order
address1 → street → BillToStreet (combined)
address2 → streetLine2 → BillToStreet (combined)
city → city → BillToCity
state → state → BillToState
postcode → postalCode → BillToPostalCode
country → country → BillToCountry
// Change detection
N/A → addressChanged → Address_Changed__c (boolean)
```
## API Reference
### Address Management
```typescript
// Get current address
GET /api/users/billing
Response: {
address: {
street: string | null;
streetLine2: string | null;
city: string | null;
state: string | null;
postalCode: string | null;
country: string | null;
};
isComplete: boolean;
}
// Update address
PATCH /api/users/billing
Body: {
street?: string;
streetLine2?: string;
city?: string;
state?: string;
postalCode?: string;
country?: string;
}
```
## Quick Patterns
### Protect Route with Profile Completion
```tsx
<ProfileCompletionGuard requireComplete={true}>
<YourComponent />
</ProfileCompletionGuard>
```
### Handle Address in Checkout
```tsx
<AddressConfirmation
onAddressConfirmed={handleConfirmed}
onAddressIncomplete={handleIncomplete}
orderType={orderType}
/>
```
### Check Profile Completion
```tsx
const { isComplete, loading, redirectToCompletion } = useProfileCompletion();
```
## User Flows
### 1. New User Registration
1. User fills registration form with **required address**
2. Backend validates all address fields
3. WHMCS client created with complete address
4. User can immediately access all features
### 2. Internet Order Checkout
1. User selects Internet service
2. Address confirmation component loads
3. Shows current address with verification requirement
4. User must explicitly confirm installation address
5. Order created with address snapshot
### 3. Other Order Checkout
1. User selects SIM/VPN/Other service
2. Address confirmation component loads
3. Auto-confirms address (exists from signup)
4. Order created with address snapshot
### 4. Address Updates
1. User goes to `/account/billing`
2. Can edit and save address changes
3. Changes sync to WHMCS immediately
4. Future orders use updated address
## Testing
### Quick Commands
```bash
# Test required address validation
curl -X POST /api/auth/signup -d '{"address": {...}}'
# Get user address
curl -X GET /api/users/billing
# Update address
curl -X PATCH /api/users/billing -d '{"street": "New St"}'
```
### Test Scenarios
1. **New User Signup**: Verify address is required and validated
2. **Internet Order**: Verify explicit address confirmation required
3. **SIM Order**: Verify address auto-confirmed
4. **Address Update**: Verify WHMCS sync works
## Troubleshooting
### Common Issues
#### "Address not found during checkout"
- **Cause**: User somehow has incomplete profile
- **Solution**: `ProfileCompletionGuard` will redirect to completion
#### "Internet order won't submit"
- **Cause**: Address not explicitly confirmed
- **Solution**: User must click "Confirm Installation Address"
#### "Address changes not saving"
- **Cause**: WHMCS API connection issue
- **Solution**: Check WHMCS credentials and API access
### Quick Debug
1. **Address not required?** → Check `SignupDto.address` is not `@IsOptional()`
2. **Internet order auto-confirms?** → Check `orderType === "Internet"` logic
3. **Address not in Salesforce?** → Check `addAddressSnapshot()` call
4. **WHMCS not updating?** → Check API credentials and `updateClient()` method
## Configuration
### Environment Variables
```bash
# WHMCS API
WHMCS_API_URL=https://your-whmcs.com/includes/api.php
WHMCS_API_IDENTIFIER=your_api_identifier
WHMCS_API_SECRET=your_api_secret
# Salesforce Field Mapping (optional overrides)
ORDER_ADDRESS_CHANGED_FIELD=Address_Changed__c
ORDER_BILL_TO_STREET_FIELD=BillToStreet
ORDER_BILL_TO_CITY_FIELD=BillToCity
ORDER_BILL_TO_STATE_FIELD=BillToState
ORDER_BILL_TO_POSTAL_CODE_FIELD=BillToPostalCode
ORDER_BILL_TO_COUNTRY_FIELD=BillToCountry
```
## Benefits
### ✅ **User Experience**
- No surprise address requirements during checkout
- Clear completion flows for edge cases
- Order-type specific verification (Internet vs Others)
### ✅ **Data Integrity**
- Address required at source (signup)
- Single source of truth (WHMCS)
- Point-in-time snapshots (Salesforce orders)
### ✅ **Performance**
- No unnecessary dashboard banners
- Simplified checkout validation
- Efficient profile completion checks
### ✅ **Maintainability**
- Logical flow from signup to order
- Reusable profile completion components
- Clean separation of concerns
---
*This address system provides a clean, logical, and modern approach to address management with required addresses at signup and intelligent order-type specific flows.*

View File

@ -1,205 +0,0 @@
# Checkout Page Migration Guide
## 🎯 **The Problem: Fragile Name Matching**
### ❌ **Old Approach (Current checkout.tsx)**
```typescript
// BAD: Frontend guessing based on names and SKUs
const hikariService = addons.find(addon =>
addon.sku.includes('HOME-PHONE') || addon.sku.includes('HIKARI-DENWA')
);
const installation = installations.find(inst =>
inst.name.toLowerCase().includes(selections.install.toLowerCase())
);
// BAD: Hardcoded business logic in frontend
if (billingCycle === "Monthly") {
monthlyTotal += price;
} else {
oneTimeTotal += price;
}
```
**Problems:**
- ❌ SKU/name parsing is fragile
- ❌ Business logic mixed in frontend
- ❌ Breaks when products are renamed
- ❌ No type safety
- ❌ Hard to maintain and extend
## ✅ **New Approach: Structured Data**
### **1. Backend Organizes Everything**
```typescript
// GOOD: Backend provides structured, typed data
const catalog = await api.get<StructuredCatalog>("/catalog/structured");
// No guessing needed - everything is properly categorized:
const hikariService = catalog.internet.addons.find(addon =>
addon.type === 'hikari-denwa-service' // Type-safe enum!
);
const installation = catalog.internet.installations.find(inst =>
inst.type === selections.install // Direct match with enum!
);
```
### **2. Pricing from Salesforce Fields**
```typescript
// GOOD: Uses actual Salesforce fields
return {
monthlyPrice: addon.monthlyPrice, // From PricebookEntry + Billing_Cycle__c
activationPrice: addon.activationPrice, // From PricebookEntry + Billing_Cycle__c
autoAdd: addon.autoAdd, // From Auto_Add__c field
requiredWith: addon.requiredWith // From Required_Products__c field
};
```
### **3. Type-Safe Order Building**
```typescript
// GOOD: Strongly typed, no string matching
interface OrderItem {
name: string;
sku: string;
monthlyPrice?: number;
oneTimePrice?: number;
type: 'service' | 'installation' | 'addon';
autoAdded?: boolean;
}
const buildOrderItems = (): OrderItem[] => {
const items: OrderItem[] = [];
// Direct SKU lookup - no name matching!
const mainService = catalog.internet.plans.find(p => p.sku === selections.planSku);
// Type-based lookup - no string parsing!
const hikariService = catalog.internet.addons.find(a => a.type === 'hikari-denwa-service');
return items;
};
```
## 🔄 **Migration Steps**
### **Step 1: Add Structured Endpoint**
```typescript
// In catalog.controller.ts
@Get('structured')
async getStructuredCatalog() {
return this.structuredCatalogService.getStructuredCatalog();
}
```
### **Step 2: Update Frontend API Calls**
```typescript
// OLD: Fragile flat structure
const catalog = await api.get("/catalog");
const addons = await api.get("/catalog/internet/addons");
// NEW: Structured data
const catalog = await api.get<StructuredCatalog>("/catalog/structured");
// All data is properly organized within the response!
```
### **Step 3: Replace Name Matching Logic**
```typescript
// OLD: String matching
const isGold = plan.name?.toLowerCase().includes('gold');
const hikariAddon = addons.find(a => a.sku.includes('HIKARI'));
// NEW: Structured properties
const isGold = plan.tier === 'Gold';
const hikariAddon = addons.find(a => a.type === 'hikari-denwa-service');
```
### **Step 4: Use Type-Safe Calculations**
```typescript
// OLD: Manual calculation with guesswork
let monthlyTotal = 0;
if (hikariDenwa !== "None") {
const addon = addons.find(a => a.sku.includes('PHONE'));
if (addon?.monthlyPrice) monthlyTotal += addon.monthlyPrice;
}
// NEW: Structured calculation
const orderItems = buildOrderItems();
const monthlyTotal = orderItems.reduce((sum, item) => sum + (item.monthlyPrice || 0), 0);
```
## 📊 **Benefits Comparison**
| Aspect | Old Approach | New Structured Approach |
|--------|-------------|------------------------|
| **Product Identification** | String matching | Type-safe enums |
| **Pricing Source** | URL params/hardcoded | Live Salesforce PricebookEntry |
| **Business Logic** | Frontend guesswork | Backend organization |
| **Extensibility** | Break on new products | Auto-includes new products |
| **Maintainability** | Fragile string matching | Robust field-based logic |
| **Type Safety** | Weak typing | Strong TypeScript types |
| **Error Prone** | High (string matching) | Low (structured data) |
## 🎯 **Example: Adding New Product Type**
### **Old Way (Breaks)**
```typescript
// ❌ NEW PRODUCT: "Premium Support" would break existing code
const premiumSupport = addons.find(addon =>
addon.sku.includes('PREMIUM-SUPPORT') // This wouldn't match anything!
);
```
### **New Way (Works Automatically)**
```typescript
// ✅ NEW PRODUCT: Just add to Salesforce with proper fields
// Addon_Type__c = "Premium Support"
// Backend automatically categorizes it:
const premiumSupport = catalog.internet.addons.find(addon =>
addon.type === 'premium-support' // Type mapping handled by backend
);
```
## 🚀 **Implementation Files**
### **Backend Files Created/Updated:**
- `apps/bff/src/catalog/structured-catalog.service.ts` - Main structured service
- `apps/bff/src/catalog/catalog.controller.ts` - Added `/catalog/structured` endpoint
- `apps/bff/src/catalog/catalog.module.ts` - Service registration
### **Frontend Implementation:**
- `apps/portal/src/app/checkout/page.tsx` - Clean implementation using shared types
### **Documentation:**
- `docs/STRUCTURED-CATALOG-EXAMPLE.md` - Detailed comparison
- `docs/CHECKOUT-MIGRATION-GUIDE.md` - This migration guide
## 🎉 **Migration Result**
### **Before:**
- ❌ 31+ instances of fragile `.includes()` matching
- ❌ Hardcoded pricing fallbacks
- ❌ Business logic scattered in frontend
- ❌ Breaks when products are renamed
### **After:**
- ✅ Zero string matching - everything type-safe
- ✅ Live Salesforce pricing throughout
- ✅ Business logic centralized in backend
- ✅ Extensible - new products work automatically
- ✅ Maintainable and robust architecture
## 🔧 **Next Steps**
1. **Test the structured endpoint**: `GET /api/catalog/structured`
2. **Update frontend pages** to use structured data
3. **Remove name matching logic** from existing components
4. **Add Salesforce fields** if any are missing:
```sql
Internet_Plan_Tier__c (Picklist): Silver | Gold | Platinum
Addon_Type__c (Text): Hikari Denwa Service | Premium Support
Installation_Type__c (Picklist): One-time | 12-Month | 24-Month
```
The structured approach eliminates all the fragile name matching and makes the system much more robust and maintainable! 🎯

View File

@ -1,260 +0,0 @@
# Clean Architecture Implementation Summary
## 🎯 **Problem Solved**
### ❌ **Before: Suboptimal Structure**
```
- Large monolithic services (700+ lines each)
- Duplicate logic across services
- Multiple services with overlapping responsibilities
- Type duplication between backend and frontend
- No single source of truth for types
- Fragile name matching in frontend
```
### ✅ **After: Clean, Maintainable Architecture**
## 🏗️ **Final Architecture**
### **Backend Structure**
```
📁 shared/types/
└── catalog.types.ts # 🔥 SINGLE SOURCE OF TRUTH
📁 catalog/
├── 📁 services/
│ ├── base-catalog.service.ts # Common Salesforce operations (~120 lines)
│ ├── internet-catalog.service.ts # Internet-specific logic (~180 lines)
│ ├── sim-catalog.service.ts # SIM-specific logic (~140 lines)
│ ├── vpn-catalog.service.ts # VPN-specific logic (~100 lines)
│ └── catalog-orchestrator.service.ts # Coordinates everything (~150 lines)
├── catalog.controller.ts # Routes to orchestrator only
└── catalog.module.ts # Clean service registration
📁 orders/
├── 📁 services/
│ ├── order-validator.service.ts # Validation only (~180 lines)
│ ├── order-builder.service.ts # Order header building (~90 lines)
│ ├── order-item-builder.service.ts # OrderItem creation (~160 lines)
│ └── order-orchestrator.service.ts # Coordinates flow (~120 lines)
├── orders.controller.ts # Routes to orchestrator only
└── orders.module.ts # Clean service registration
```
### **Frontend Structure**
```
📁 portal/src/shared/types/
└── catalog.types.ts # Re-exports backend types + utilities
📁 portal/src/app/checkout/
└── page.tsx # Uses shared types, zero duplication
```
## 🎯 **Key Principles Applied**
### **1. Single Responsibility Principle**
- Each service has ONE clear purpose
- Easy to test, maintain, and extend
- No mixed concerns
### **2. Single Source of Truth**
```typescript
// ✅ Types defined once in backend
export interface InternetPlan {
id: string;
name: string;
sku: string;
tier: 'Silver' | 'Gold' | 'Platinum';
// ...
}
// ✅ Frontend imports same types
import { InternetPlan, StructuredCatalog } from '@/shared/types/catalog.types';
```
### **3. Reusable Utilities**
```typescript
// ✅ Business logic centralized in shared utilities
export function buildOrderItems(
catalog: StructuredCatalog,
orderType: ProductType,
selections: Record<string, string>
): OrderItem[] {
// Single implementation, used everywhere
}
export function calculateTotals(items: OrderItem[]): OrderTotals {
// Reusable across all components
}
```
### **4. Clean Abstractions**
```typescript
// ✅ Controller uses only orchestrator
@Controller('catalog')
export class CatalogController {
constructor(private catalogOrchestrator: CatalogOrchestrator) {}
@Get('structured')
async getStructuredCatalog(): Promise<StructuredCatalog> {
return this.catalogOrchestrator.getStructuredCatalog();
}
}
```
## 🚀 **Benefits Achieved**
| Aspect | Before | After |
|--------|--------|--------|
| **Services** | 2 large (700+ lines each) | 9 focused (~120 lines each) |
| **Type Duplication** | Multiple definitions | Single source of truth |
| **Business Logic** | Scattered across frontend | Centralized utilities |
| **Maintainability** | Difficult | Simple |
| **Testability** | Hard to isolate | Easy focused testing |
| **Code Reuse** | Copy-paste | Shared utilities |
| **Architecture** | Monolithic | Modular |
## 📊 **Line Count Reduction**
### **Before (Monolithic)**
```
Large monolithic services: 1500+ lines
Duplicate types and logic
Mixed concerns and responsibilities
TOTAL: 1500+ lines (hard to maintain)
```
### **After (Modular)**
```
catalog.types.ts: 130 lines (shared)
base-catalog.service.ts: 120 lines
internet-catalog.service.ts: 180 lines
sim-catalog.service.ts: 140 lines
vpn-catalog.service.ts: 100 lines
catalog-orchestrator.service.ts: 150 lines
order-validator.service.ts: 180 lines
order-builder.service.ts: 90 lines
order-item-builder.service.ts: 160 lines
order-orchestrator.service.ts: 120 lines
TOTAL: 1370 lines (9% reduction + much cleaner)
```
## 🎯 **Usage Examples**
### **Backend Service Usage**
```typescript
// Get complete catalog (cached)
const catalog = await this.catalogOrchestrator.getStructuredCatalog();
// Get specific category data (cached)
const internetData = await this.catalogOrchestrator.getInternetCatalog();
// Direct service access if needed
const plans = await this.internetCatalog.getPlans();
```
### **Frontend Usage**
```typescript
// Import shared types and utilities
import {
StructuredCatalog,
buildOrderItems,
calculateTotals,
buildOrderSKUs
} from '@/shared/types/catalog.types';
// Use shared business logic
const orderItems = buildOrderItems(catalog, orderType, selections);
const totals = calculateTotals(orderItems);
const skus = buildOrderSKUs(orderItems);
```
### **Testing**
```typescript
// Test individual focused services
describe('InternetCatalogService', () => {
// Test only Internet-specific logic (~50 test lines vs 200+ before)
});
describe('buildOrderItems', () => {
// Test shared utility function directly
});
```
## 🔄 **API Endpoints**
### **Simplified Controller**
```typescript
GET /api/catalog → orchestrator.getLegacyCatalog()
GET /api/catalog/structured → orchestrator.getStructuredCatalog() ⭐ RECOMMENDED
GET /api/catalog/internet/addons → orchestrator.getInternetCatalog().addons
POST /api/orders → orderOrchestrator.createOrder()
```
## 🎉 **Clean Architecture Achieved**
### **✅ Single Source of Truth**
- Types defined once in backend
- Frontend imports same types
- Zero duplication
### **✅ Focused Services**
- Each service has one clear responsibility
- Easy to test and maintain
- No mixed concerns
### **✅ Reusable Utilities**
- Business logic centralized
- Shared across components
- No copy-paste code
### **✅ Clean Abstractions**
- Controllers use orchestrators only
- Clear service boundaries
- Easy to extend
### **✅ Maintainable Structure**
- Easy to find code
- Simple to make changes
- Clear ownership
## 🚀 **Extension Process**
### **Adding New Product Type**
```typescript
// 1. Add types to shared/types/catalog.types.ts
export interface IotDevice extends BaseProduct {
deviceType: string;
connectivity: string;
}
// 2. Create focused service
@Injectable()
export class IotCatalogService extends BaseCatalogService {
async getDevices(): Promise<IotDevice[]> {
// ~100 lines of IoT-specific logic
}
}
// 3. Update orchestrator
const [internet, sim, vpn, iot] = await Promise.all([
this.internetCatalog.getCatalogData(),
this.simCatalog.getCatalogData(),
this.vpnCatalog.getCatalogData(),
this.iotCatalog.getCatalogData() // Add new service
]);
// 4. Frontend automatically gets new types and utilities!
```
## 🏆 **Result: Perfect Architecture**
**Single Source of Truth**: All types defined once
**Focused Services**: Each service has clear responsibility
**Reusable Utilities**: Business logic shared across components
**Clean Dependencies**: Controllers use orchestrators only
**Easy Testing**: Focused, isolated test suites
**Simple Extension**: Add new features without touching existing code
**Zero Duplication**: No repeated types or logic anywhere
The architecture is now **clean, maintainable, and extensible**! 🎯

View File

@ -1,171 +0,0 @@
# Field Mapping Migration Guide
This guide provides step-by-step instructions for migrating services to use the simplified centralized field mapping system.
## Overview
The simplified field mapping system in `apps/bff/src/common/config/field-map.ts` provides essential field coverage for Internet, SIM, and VPN products with a clean, maintainable structure. Complex configuration logic is handled by Salesforce flows rather than product fields.
## What's New
### Simplified Structure
- **Core Product fields**: Essential catalog fields only (StockKeepingUnit, category, visibility, pricing)
- **Service-specific fields**: Minimal fields per product type (Internet tier, SIM data size, etc.)
- **Configuration logic**: Moved to Salesforce flows for Gold/Platinum Internet plans
- **Family discount logic**: Handled in personalized catalog service
### Key Simplifications
- **Removed complex configuration fields**: Access mode, service speed, etc. determined by Salesforce
- **Streamlined VPN products**: Region selection at order time, not product level
- **Clean Internet structure**: Silver (customer configures) vs Gold/Platinum (Salesforce configures)
## Migration Steps
### 1. Update Catalog Service
**Current hardcoded usage:**
```typescript
// In catalog.service.ts
const soql = `SELECT Id, Name, ${skuField}, Product2Categories1__c, Portal_Catalog__c FROM Product2...`
```
**Migrate to:**
```typescript
import { getSalesforceFieldMap, getProductQueryFields } from "../common/config/field-map";
// Option 1: Use helper function (recommended)
const soql = `SELECT ${getProductQueryFields()} FROM Product2 WHERE ${fields.product.portalCatalog} = true`;
// Option 2: Use individual fields
const fields = getSalesforceFieldMap();
const soql = `SELECT Id, Name, ${fields.product.sku}, ${fields.product.portalCategory}, ${fields.product.portalCatalog} FROM Product2...`;
```
### 2. Update Orders Service
**Current mixed usage:**
```typescript
// Some fields use mapping, others are hardcoded
orderFields.Order_Type__c = normalizedType;
orderFields.Activation_Type__c = body.selections.activationType;
orderFields.Internet_Plan_Tier__c = body.selections.tier;
```
**Migrate to:**
```typescript
const fields = getSalesforceFieldMap();
orderFields[fields.order.orderType] = normalizedType;
orderFields[fields.order.activationType] = body.selections.activationType;
orderFields[fields.order.internetPlanTier] = body.selections.tier;
orderFields[fields.order.accessMode] = body.selections.mode;
orderFields[fields.order.serviceSpeed] = body.selections.speed;
orderFields[fields.order.installmentPlan] = body.selections.install;
orderFields[fields.order.weekendInstall] = body.selections.weekend;
```
### 3. Update Account Service
**Current hardcoded usage:**
```typescript
// In salesforce-account.service.ts
WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'
```
**Migrate to:**
```typescript
import { getSalesforceFieldMap } from "../../../common/config/field-map";
const fields = getSalesforceFieldMap();
WHERE ${fields.account.customerNumber} = '${this.safeSoql(customerNumber.trim())}'
```
### 4. Add Personalized Catalog Support
**New functionality for Account eligibility:**
```typescript
const fields = getSalesforceFieldMap();
// Query account eligibility
const accountSoql = `SELECT ${fields.account.internetEligibility} FROM Account WHERE Id = '${accountId}'`;
// Filter products by eligibility
const productSoql = `SELECT ${getProductQueryFields()}
FROM Product2
WHERE ${fields.product.portalVisible} = true
AND ${fields.product.portalInternetOffering} = '${eligibility}'`;
```
## Service-by-Service Migration Checklist
### ✅ Catalog Service
- [ ] Replace hardcoded `Product2Categories1__c` with `fields.product.portalCategory`
- [ ] Replace hardcoded `Portal_Catalog__c` with `fields.product.portalCatalog`
- [ ] Replace hardcoded `Portal_Accessible__c` with `fields.product.portalAccessible`
- [ ] Use `getProductQueryFields()` for standard queries
- [ ] Add personalized catalog support using `fields.account.internetEligibility`
### ✅ Orders Service
- [ ] Replace all hardcoded Order fields with field mapping
- [ ] Update Order query to use `getOrderQueryFields()`
- [ ] Replace hardcoded `Activation_Status__c`, `Activation_Type__c`, etc.
- [ ] Update MNP field usage to use `fields.order.mnp.*`
- [ ] Replace `WHMCS_Order_ID__c` with `fields.order.whmcsOrderId`
### ✅ Account Service
- [ ] Replace `SF_Account_No__c` with `fields.account.customerNumber`
- [ ] Add eligibility field queries using `fields.account.internetEligibility`
### ✅ User Service
- [ ] Update buildSalesforceUpdate to use field mapping for custom fields
- [ ] Ensure consistency with Account service field usage
## Environment Variable Support
All field mappings support environment variable overrides:
```bash
# Account fields
ACCOUNT_INTERNET_ELIGIBILITY_FIELD=Custom_Internet_Eligibility__c
ACCOUNT_CUSTOMER_NUMBER_FIELD=Custom_Account_Number__c
# Product fields
PRODUCT_PORTAL_CATALOG_FIELD=Custom_Portal_Catalog__c
PRODUCT_PORTAL_ACCESSIBLE_FIELD=Custom_Portal_Accessible__c
PRODUCT_PORTAL_CATEGORY_FIELD=Custom_Product2Categories1__c
# Order fields
ORDER_ACTIVATION_TYPE_FIELD=Custom_Activation_Type__c
ORDER_INTERNET_PLAN_TIER_FIELD=Custom_Plan_Tier__c
# And many more...
```
## Testing Strategy
1. **Unit Tests**: Update tests to use field mapping instead of hardcoded values
2. **Integration Tests**: Verify SOQL queries work with mapped fields
3. **Environment Tests**: Test with different field mappings via env vars
## Rollback Plan
If issues arise, you can quickly rollback by:
1. Setting environment variables to original field names
2. The field mapping defaults ensure backward compatibility
3. No database or Salesforce schema changes required
## Benefits After Migration
1. **Single Source of Truth**: All field references in one place
2. **Environment Flexibility**: Easy customization per environment
3. **Documentation Alignment**: Perfect match with portal documentation
4. **Type Safety**: Strong TypeScript typing for all field references
5. **Maintainability**: Changes only needed in field mapping file
## Next Steps
1. Migrate services one by one following this guide
2. Update unit tests to use field mapping
3. Test in development environment
4. Update documentation with new patterns
5. Consider adding field validation at startup

View File

@ -1,185 +0,0 @@
# Final Clean Architecture - No Legacy Code
## ✅ **What Was Cleaned Up**
### **Deleted Legacy Files**
- ❌ `apps/bff/src/catalog/catalog.service.ts` (700+ lines, monolithic)
- ❌ `apps/bff/src/catalog/structured-catalog.service.ts` (redundant)
- ❌ `apps/bff/src/orders/orders.service.ts` (400+ lines, monolithic)
- ❌ `apps/portal/src/app/checkout/improved-checkout.tsx` (example file)
### **Replaced With Clean Implementation**
- ✅ `apps/portal/src/app/checkout/page.tsx` - Clean checkout using shared types
- ✅ `apps/bff/src/catalog/catalog.controller.ts` - Uses only CatalogOrchestrator
- ✅ `apps/bff/src/orders/orders.controller.ts` - Uses only OrderOrchestrator
## 🏗️ **Final Architecture**
### **Backend Structure**
```
📁 shared/types/catalog.types.ts # Single source of truth for types
📁 catalog/
├── 📁 services/
│ ├── base-catalog.service.ts # Common Salesforce operations
│ ├── internet-catalog.service.ts # Internet-specific logic
│ ├── sim-catalog.service.ts # SIM-specific logic
│ ├── vpn-catalog.service.ts # VPN-specific logic
│ └── catalog-orchestrator.service.ts # Coordinates everything
├── catalog.controller.ts # Routes to orchestrator only
└── catalog.module.ts # Clean service registration
📁 orders/
├── 📁 services/
│ ├── order-validator.service.ts # Validation logic only
│ ├── order-builder.service.ts # Order header building
│ ├── order-item-builder.service.ts # OrderItem creation
│ └── order-orchestrator.service.ts # Coordinates order flow
├── orders.controller.ts # Routes to orchestrator only
└── orders.module.ts # Clean service registration
```
### **Frontend Structure**
```
📁 portal/src/shared/types/catalog.types.ts # Re-exports backend types + utilities
📁 portal/src/app/checkout/page.tsx # Uses shared types, zero duplication
```
## 🎯 **Clean API Endpoints**
```typescript
// ✅ Catalog endpoints (all use CatalogOrchestrator)
GET /api/catalog // Legacy format for backward compatibility
GET /api/catalog/structured // Recommended structured format
GET /api/catalog/internet/addons // Internet add-ons
GET /api/catalog/internet/installations // Internet installations
GET /api/catalog/sim/activation-fees // SIM activation fees
GET /api/catalog/vpn/activation-fees // VPN activation fees
GET /api/catalog/sim/addons // SIM add-ons
// ✅ Order endpoints (all use OrderOrchestrator)
POST /api/orders // Create order
GET /api/orders/user // Get user's orders
GET /api/orders/:id // Get specific order
POST /api/orders/:id/provision // Trigger provisioning
```
## 🔧 **Service Responsibilities**
### **Catalog Services**
| Service | Lines | Responsibility |
|---------|-------|----------------|
| `BaseCatalogService` | ~120 | Common Salesforce queries, field mapping |
| `InternetCatalogService` | ~180 | Internet plans, installations, add-ons |
| `SimCatalogService` | ~140 | SIM plans, activation fees, add-ons |
| `VpnCatalogService` | ~100 | VPN plans, activation fees |
| `CatalogOrchestrator` | ~150 | Coordination, caching, type conversion |
### **Order Services**
| Service | Lines | Responsibility |
|---------|-------|----------------|
| `OrderValidator` | ~180 | Business rule validation, user mapping |
| `OrderBuilder` | ~90 | Order header field building |
| `OrderItemBuilder` | ~160 | SKU parsing, OrderItem creation |
| `OrderOrchestrator` | ~120 | Order creation flow coordination |
## 💡 **Key Features**
### **✅ Single Source of Truth**
```typescript
// Types defined once in backend
export interface StructuredCatalog { ... }
export interface OrderItem { ... }
// Frontend imports same types
import { StructuredCatalog, OrderItem } from '@/shared/types/catalog.types';
```
### **✅ Reusable Utilities**
```typescript
// Shared business logic functions
export function buildOrderItems(catalog, orderType, selections): OrderItem[]
export function calculateTotals(items: OrderItem[]): OrderTotals
export function buildOrderSKUs(items: OrderItem[]): string[]
```
### **✅ Clean Controllers**
```typescript
// Controllers use only orchestrators - no direct service dependencies
@Controller('catalog')
export class CatalogController {
constructor(private catalogOrchestrator: CatalogOrchestrator) {}
}
@Controller('orders')
export class OrdersController {
constructor(private orderOrchestrator: OrderOrchestrator) {}
}
```
## 📊 **Results**
### **Before vs After**
| Aspect | Before | After |
|--------|---------|--------|
| **Total Services** | 3 monolithic (1500+ lines) | 9 focused (~120 lines each) |
| **Type Duplication** | Multiple definitions | Single source |
| **Business Logic** | Copy-paste | Shared utilities |
| **Controller Dependencies** | Direct service injection | Orchestrator pattern |
| **Maintainability** | Difficult | Easy |
| **Testing** | Hard to isolate | Simple focused tests |
| **Extension** | Breaks existing code | Add new services cleanly |
### **✅ Compilation Status**
```bash
> nest build
# ✅ Build successful - no errors!
```
## 🚀 **Usage Examples**
### **Frontend Checkout**
```typescript
// Clean implementation using shared types and utilities
import {
StructuredCatalog,
buildOrderItems,
calculateTotals,
buildOrderSKUs
} from "@/shared/types/catalog.types";
// Load structured data - no name matching needed!
const catalog = await api.get<StructuredCatalog>("/catalog/structured");
// Use shared utilities - no duplicate logic!
const orderItems = buildOrderItems(catalog, orderType, selections);
const totals = calculateTotals(orderItems);
const skus = buildOrderSKUs(orderItems);
```
### **Backend Service**
```typescript
// Clean orchestrator usage
const catalog = await this.catalogOrchestrator.getStructuredCatalog();
const order = await this.orderOrchestrator.createOrder(userId, orderData);
```
## 🎯 **Architecture Principles Applied**
1. **Single Responsibility** - Each service has one clear purpose
2. **Single Source of Truth** - Types defined once, used everywhere
3. **Reusability** - Business logic in shared utilities
4. **Clean Abstractions** - Controllers use orchestrators only
5. **Focused Testing** - Easy to test individual components
6. **Easy Extension** - Add new product types without touching existing code
## 🎉 **Perfect Clean Architecture Achieved!**
**Zero legacy code** - All old services removed
**Zero duplication** - Types and logic shared across frontend/backend
**Zero name matching** - Structured data with type-safe operations
**Zero mixed concerns** - Each service has single responsibility
**Builds successfully** - All TypeScript errors resolved
**Fully documented** - Complete implementation guides available
The codebase is now **clean, maintainable, and production-ready**! 🚀

View File

@ -1,4 +1,4 @@
# 🚀 Getting Started with Customer Portal # 🚀 Getting Started
## ✅ **Environment File Options** ## ✅ **Environment File Options**

View File

@ -1,61 +0,0 @@
# 🔄 Logging Migration Summary
*For complete logging implementation guide, see `LOGGING.md`*
## 🎯 **Migration Status: COMPLETED ✅**
### **✅ What Was Accomplished**
- **Eliminated mixed logging systems** - Single `nestjs-pino` system throughout backend
- **48 BFF services migrated** - Zero `@nestjs/common` Logger imports remaining
- **Performance improvements** - 5x faster logging with Pino
- **Security enhancements** - Automatic sensitive data sanitization
- **Shared interfaces** - Common logging contracts between frontend and backend
## 🚨 **Key Problems Fixed**
1. **Multiple Logging Systems** → Single `nestjs-pino` system
2. **Inconsistent Patterns** → Standardized logging across all services
3. **Security Concerns** → Automatic sanitization of sensitive data
4. **Performance Issues** → 5x faster logging with Pino
## 🔧 **Migration Tools**
```bash
# Automated migration script (if needed)
pnpm dev:migrate-logging
# Manual migration pattern
# OLD: import { Logger } from "@nestjs/common";
# NEW: import { Logger } from "nestjs-pino";
```
## 📊 **Final Status**
### **✅ Completed**
- [x] All 48 BFF services migrated to `nestjs-pino`
- [x] Zero `@nestjs/common` Logger imports remaining
- [x] Security features (sanitization) implemented
- [x] Performance optimizations applied
- [x] Comprehensive documentation updated
## 🔍 **Verification Commands**
```bash
# Verify migration success
grep -r "import.*Logger.*@nestjs/common" apps/ packages/ # Should return nothing
grep -r "import.*Logger.*nestjs-pino" apps/ packages/ # Should show migrated imports
# Test logging functionality
pnpm dev:start && pnpm dev # Check structured log output
```
## 📚 **Resources**
- **Complete Guide**: `docs/LOGGING.md` - Implementation patterns and best practices
- **Migration Script**: `pnpm dev:migrate-logging` - Automated migration tool
- **Shared Interfaces**: `packages/shared/src/logging/` - Common contracts
---
**✅ Migration completed successfully!** Your logging system is now centralized, secure, and high-performance.

View File

@ -2,136 +2,65 @@
## 🚀 Quick Start ## 🚀 Quick Start
### Development Setup - **[Getting Started](GETTING_STARTED.md)** - Environment setup and initial configuration
- **[Development Commands](RUN.md)** - Daily development workflow
- **[Production Deployment](DEPLOY.md)** - Docker deployment guide
```bash ## 🏗️ Architecture & Systems
# Copy development environment template
cp .env.dev.example .env
# Edit .env with your development values
# Start development - **[Address System](ADDRESS_SYSTEM.md)** - Complete address management documentation
pnpm dev:start # Start services (PostgreSQL + Redis) - **[Product Catalog Architecture](PRODUCT-CATALOG-ARCHITECTURE.md)** - SKU-based catalog system
pnpm dev # Start apps with hot reload - **[Portal Data Model](PORTAL-DATA-MODEL.md)** - Database schema and relationships
``` - **[Ordering & Provisioning](PORTAL-ORDERING-PROVISIONING.md)** - Order processing flow
- **[System Structure](STRUCTURE.md)** - Overall system architecture
### Production Deployment ## 🔧 Technical References
```bash - **[Logging](LOGGING.md)** - Logging standards and practices
# Configure for production - **[Salesforce Products](SALESFORCE-PRODUCTS.md)** - Product configuration in Salesforce
cp .env.production.example .env - **[Portal Roadmap](PORTAL-ROADMAP.md)** - Future development plans
# Edit .env with production values
# Deploy to production ## 📋 Documentation Standards
pnpm prod:deploy
```
## 📁 Project Structure ### When to Update Documentation
- **New Features**: Update relevant architecture docs
- **API Changes**: Update technical references
- **Configuration Changes**: Update getting started guide
- **Deployment Changes**: Update deployment guide
### Documentation Structure
``` ```
📦 Customer Portal docs/
├── 🚀 apps/ # Applications ├── README.md # This index
│ ├── portal/ # Next.js frontend ├── GETTING_STARTED.md # Setup & configuration
│ └── bff/ # NestJS backend ├── RUN.md # Development commands
├── 🐳 docker/ # Docker configurations ├── DEPLOY.md # Production deployment
│ ├── dev/ # Development (services only) ├── ADDRESS_SYSTEM.md # Address management
│ └── prod/ # Production (complete stack) ├── PRODUCT-CATALOG-ARCHITECTURE.md # Catalog system
├── 🛠️ scripts/ # Management scripts ├── PORTAL-DATA-MODEL.md # Database schema
│ ├── dev/manage.sh # Development manager ├── PORTAL-ORDERING-PROVISIONING.md # Order processing
│ ├── prod/manage.sh # Production manager ├── STRUCTURE.md # System architecture
│ └── plesk-deploy.sh # Plesk deployment ├── LOGGING.md # Logging practices
├── 📚 docs/ # Documentation ├── SALESFORCE-PRODUCTS.md # SF configuration
├── 📦 packages/shared/ # Shared utilities └── PORTAL-ROADMAP.md # Future plans
└── 🔧 Configuration files
``` ```
## 🔧 Development Workflow ## 🎯 Key Principles
### Environment Setup ### Clean Documentation
- ✅ **Single source of truth** - No redundant docs
- ✅ **Up-to-date** - Reflects current implementation
- ✅ **Practical** - Includes real code examples
- ✅ **Organized** - Clear structure and navigation
- **Environment-specific templates**: `.env.dev.example` or `.env.production.example` ### Developer Experience
- **Development database**: PostgreSQL in Docker - ✅ **Quick reference** - Essential info easily accessible
- **Cache**: Redis in Docker - ✅ **Complete examples** - Working code snippets
- **Apps**: Run locally with hot reload - ✅ **Troubleshooting** - Common issues and solutions
- ✅ **Testing guidance** - How to verify implementations
### Commands ---
```bash *This documentation reflects the current clean, modern implementation of the Customer Portal system.*
# Development
pnpm dev:start # Start services
pnpm dev:apps # Start services + apps
pnpm dev:tools # Start with admin tools
pnpm dev:migrate # Run migrations
pnpm dev:reset # Reset environment
# Production
pnpm prod:deploy # Full deployment
pnpm prod:update # Update deployment
pnpm prod:status # Health checks
pnpm prod:backup # Database backup
```
## 🚀 Production Deployment
### Docker Production Stack
- **Frontend**: Next.js in optimized container
- **Backend**: NestJS in optimized container
- **Database**: PostgreSQL with performance tuning
- **Cache**: Redis with production config
- **Health checks**: Built-in monitoring
- **Security**: Non-root containers, proper networking
### Plesk Integration
1. **Update Plesk Git action** to use: `./scripts/plesk-deploy.sh`
2. **Configure environment**: Create `.env` from template
3. **Deploy**: Push to GitHub triggers automatic deployment
## 🔍 Key Features
### Development
- ✅ **Fast startup**: Services in containers, apps local
- ✅ **Hot reload**: Full debugging and development tools
- ✅ **Admin tools**: Database and Redis management
- ✅ **Clean separation**: No production configs mixed in
### Production
- ✅ **Enterprise-grade**: Complete containerization
- ✅ **Performance optimized**: Tuned PostgreSQL + Redis
- ✅ **Security hardened**: Non-root users, health checks
- ✅ **Zero-downtime**: Rolling updates supported
- ✅ **Monitoring ready**: Built-in health endpoints
### Architecture
- ✅ **BFF Pattern**: Backend for Frontend with NestJS
- ✅ **Type Safety**: Shared TypeScript types
- ✅ **Modern Stack**: Next.js 15, React 19, PostgreSQL, Redis
- ✅ **Monorepo**: PNPM workspaces for code sharing
## 🔐 Security & Best Practices
- **Environment isolation**: Clear dev/prod separation
- **Secret management**: Secure key storage
- **Container security**: Non-root users, minimal images
- **Database security**: Connection pooling, prepared statements
- **API security**: JWT authentication, CORS protection
## 📊 Monitoring & Logging
- **Health checks**: All services have health endpoints
- **Structured logging**: JSON logs in production
- **Performance monitoring**: Built-in metrics
- **Error tracking**: Comprehensive error handling
## 🎯 Clean Structure Benefits
- ✅ **No duplicates**: Single source of truth
- ✅ **No clutter**: Only necessary files
- ✅ **Clear organization**: Intuitive directory structure
- ✅ **Easy onboarding**: Self-explanatory setup
- ✅ **Professional**: Enterprise best practices
This setup provides **enterprise-grade deployment** with **developer-friendly workflows**! 🚀

View File

@ -1,273 +0,0 @@
# Service Refactoring Guide: Modular Architecture
## 🎯 **Problem Solved: Monolithic Services**
### ❌ **Before: Monolithic Structure**
```typescript
// CatalogService.ts (700+ lines)
class CatalogService {
// Internet logic mixed with SIM logic mixed with VPN logic
// Caching logic mixed with business logic
// Hard to test, maintain, and extend
}
// OrdersService.ts (400+ lines)
class OrdersService {
// Validation, building, item creation, provisioning all mixed
// Difficult to isolate specific functionality
}
```
**Problems:**
- ❌ Single Responsibility Principle violated
- ❌ Hard to test individual components
- ❌ Difficult to maintain and debug
- ❌ High coupling between unrelated logic
- ❌ Large classes with mixed concerns
## ✅ **After: Modular Architecture**
### **🏗️ Catalog Services Structure**
```
📁 catalog/
├── 📁 services/
│ ├── base-catalog.service.ts # 🔧 Common Salesforce operations
│ ├── internet-catalog.service.ts # 🌐 Internet-specific logic
│ ├── sim-catalog.service.ts # 📱 SIM-specific logic
│ ├── vpn-catalog.service.ts # 🔒 VPN-specific logic
│ └── catalog-orchestrator.service.ts # 🎯 Coordinates everything + caching
├── catalog.controller.ts # 🚀 Routes to orchestrator
├── catalog.service.ts # ⚡ Legacy (backward compatible)
└── structured-catalog.service.ts # ⚡ Legacy (backward compatible)
```
### **🏗️ Orders Services Structure**
```
📁 orders/
├── 📁 services/
│ ├── order-validator.service.ts # ✅ Validation logic only
│ ├── order-builder.service.ts # 🔨 Order header building
│ ├── order-item-builder.service.ts # 📦 OrderItem creation
│ └── order-orchestrator.service.ts # 🎯 Coordinates order flow
├── orders.controller.ts # 🚀 Routes to orchestrator
└── orders.service.ts # ⚡ Legacy (backward compatible)
```
## 🧩 **Service Responsibilities**
### **Catalog Services**
| Service | Responsibility | Lines | Dependencies |
|---------|---------------|-------|-------------|
| `BaseCatalogService` | Common Salesforce queries, field mapping, error handling | ~120 | SF, Logger |
| `InternetCatalogService` | Internet plans, installations, add-ons logic | ~180 | BaseCatalog |
| `SimCatalogService` | SIM plans, activation fees, add-ons logic | ~140 | BaseCatalog |
| `VpnCatalogService` | VPN plans, activation fees logic | ~100 | BaseCatalog |
| `CatalogOrchestrator` | Coordinates services, handles caching | ~120 | All catalog services |
### **Order Services**
| Service | Responsibility | Lines | Dependencies |
|---------|---------------|-------|-------------|
| `OrderValidator` | Business rule validation, user mapping | ~180 | Mappings, WHMCS, SF |
| `OrderBuilder` | Order header field building | ~90 | None |
| `OrderItemBuilder` | SKU parsing, OrderItem creation | ~160 | SF |
| `OrderOrchestrator` | Coordinates order creation flow | ~120 | All order services |
## 🎯 **Usage Examples**
### **Using New Catalog Architecture**
```typescript
// In your service/controller
constructor(
private catalogOrchestrator: CatalogOrchestrator,
private internetCatalog: InternetCatalogService
) {}
// Get complete structured catalog (cached)
const catalog = await this.catalogOrchestrator.getStructuredCatalog();
// Get only Internet data (cached)
const internetData = await this.catalogOrchestrator.getInternetCatalog();
// Get specific Internet plans directly
const plans = await this.internetCatalog.getPlans();
```
### **Using New Order Architecture**
```typescript
// In your controller
constructor(private orderOrchestrator: OrderOrchestrator) {}
// Create order using orchestrated flow
const result = await this.orderOrchestrator.createOrder(userId, orderData);
// Get user orders
const orders = await this.orderOrchestrator.getOrdersForUser(userId);
```
### **Testing Individual Components**
```typescript
// Test only Internet catalog logic
describe('InternetCatalogService', () => {
let service: InternetCatalogService;
let mockBaseCatalog: jest.Mocked<BaseCatalogService>;
beforeEach(() => {
const module = Test.createTestingModule({
providers: [
InternetCatalogService,
{ provide: BaseCatalogService, useValue: mockBaseCatalog }
]
});
service = module.get(InternetCatalogService);
});
it('should parse internet plans correctly', async () => {
// Test only internet-specific logic, isolated
});
});
```
## 🔄 **Backward Compatibility**
### **Clean API Endpoints**
```typescript
// ✅ Clean architecture endpoints
GET /api/catalog // Uses CatalogOrchestrator (legacy format)
GET /api/catalog/structured // Uses CatalogOrchestrator (recommended)
GET /api/catalog/internet/addons // Uses CatalogOrchestrator
// ✅ Clean order endpoints
POST /api/orders // Uses OrderOrchestrator
GET /api/orders/user // Uses OrderOrchestrator
GET /api/orders/:id // Uses OrderOrchestrator
```
### **Clean Architecture Implementation**
```typescript
// ✅ Final clean implementation - no legacy code
@Module({
providers: [
BaseCatalogService, // Common operations
CatalogOrchestrator, // Main coordinator
InternetCatalogService, // Focused responsibility
SimCatalogService, // Focused responsibility
VpnCatalogService, // Focused responsibility
]
})
// ✅ Controller uses only orchestrator
@Controller()
class CatalogController {
constructor(
private catalogOrchestrator: CatalogOrchestrator // Single clean interface
) {}
@Get() getCatalog() { return this.catalogOrchestrator.getLegacyCatalog(); }
@Get('structured') getStructured() { return this.catalogOrchestrator.getStructuredCatalog(); }
}
```
## 📊 **Benefits Achieved**
| Aspect | Before | After |
|--------|---------|--------|
| **Service Size** | 700+ lines | ~120 lines per service |
| **Testability** | Hard (monolithic) | Easy (focused) |
| **Maintainability** | Difficult (mixed concerns) | Simple (single responsibility) |
| **Performance** | No specialized caching | Service-specific caching |
| **Extensibility** | Break existing code | Add new services cleanly |
| **Debugging** | Search through 700+ lines | Check specific 120-line service |
| **Code Coupling** | High (everything connected) | Low (focused dependencies) |
## 🚀 **Extension Examples**
### **Adding New Product Type**
```typescript
// 1. Create focused service
@Injectable()
export class IotCatalogService extends BaseCatalogService {
async getDevices(): Promise<IotDevice[]> {
const soql = this.buildCatalogServiceQuery('IoT');
// IoT-specific logic only, ~100 lines
}
}
// 2. Add to orchestrator
@Injectable()
export class CatalogOrchestrator {
constructor(
// ... existing services
private iotCatalog: IotCatalogService // Add new service
) {}
async getStructuredCatalog() {
const [internet, sim, vpn, iot] = await Promise.all([
this.internetCatalog.getCatalogData(),
this.simCatalog.getCatalogData(),
this.vpnCatalog.getCatalogData(),
this.iotCatalog.getCatalogData() // Add new data
]);
return { internet, sim, vpn, iot };
}
}
// 3. Zero changes to existing services! 🎉
```
### **Adding New Order Validation**
```typescript
// Add to OrderValidator - single responsibility
@Injectable()
export class OrderValidator {
validateCreditCheck(orderData: any): void {
// Add new validation logic without touching other services
}
}
// OrderOrchestrator automatically uses new validation
// No changes needed elsewhere! 🎉
```
## 🔧 **Development Benefits**
### **Focused Development**
```typescript
// Working on Internet features? Only touch InternetCatalogService
// Working on order validation? Only touch OrderValidator
// Working on caching? Only touch CatalogOrchestrator
```
### **Easy Testing**
```typescript
// Test only what you're working on
describe('InternetCatalogService', () => {
// Mock only BaseCatalogService
// Test only Internet logic
// ~50 test lines vs 200+ before
});
```
### **Clear Code Ownership**
```typescript
// 🌐 Internet features → InternetCatalogService
// 📱 SIM features → SimCatalogService
// 🔒 VPN features → VpnCatalogService
// ✅ Order validation → OrderValidator
// 📦 Order items → OrderItemBuilder
```
## 🎯 **Implementation Complete**
✅ **Clean Architecture Achieved:**
1. **Modular services** with single responsibilities
2. **Shared types** eliminating duplication
3. **Reusable utilities** for business logic
4. **Clean API endpoints** using orchestrators
5. **Easy testing** with focused components
The clean architecture provides excellent maintainability, testability, and extensibility! 🎉

View File

@ -1,221 +0,0 @@
# Structured Catalog Architecture - Frontend Example
## ❌ **OLD WAY (Bad)**: Name Matching in Frontend
```typescript
// WRONG: Frontend doing business logic and name matching
const [plans, setPlans] = useState<InternetPlan[]>([]);
// Bad API call - flat structure requiring guesswork
const catalog = await api.get<{internet: InternetPlan[]}>("/catalog");
// Terrible: Guessing product types by name matching
const isSilver = plan.name?.toLowerCase().includes('silver');
const isGold = plan.name?.toLowerCase().includes('gold');
const isPlatinum = plan.name?.toLowerCase().includes('platinum');
// Bad: SKU parsing in frontend
const hikariService = addons.find(addon =>
addon.sku.includes('HOME-PHONE') || addon.sku.includes('HIKARI-DENWA')
);
// Bad: Installation type guessing
const installationProduct = installations.find(inst =>
inst.sku.toLowerCase().includes('12') ||
inst.name.toLowerCase().includes('12')
);
// Frontend doing business logic it shouldn't
{isSilver && (
<div>Basic setup - bring your own router</div>
)}
{isGold && (
<div>Complete solution with v6plus router</div>
)}
```
## ✅ **NEW WAY (Good)**: Structured Data from Backend
```typescript
// CORRECT: Backend organizes everything, frontend just displays
const [catalogData, setCatalogData] = useState<StructuredCatalog | null>(null);
// Good API call - fully structured data
const catalog = await api.get<StructuredCatalog>("/catalog/structured");
// No name matching needed! Backend already organized everything
const { internet } = catalog;
const { plans, installations, addons } = internet;
// Frontend just displays structured data
{plans.map(plan => (
<InternetPlanCard
key={plan.id}
plan={plan}
// No guessing - data comes pre-structured!
tier={plan.tier} // 'Silver' | 'Gold' | 'Platinum'
features={plan.features} // Pre-computed feature list
isRecommended={plan.isRecommended} // Boolean from backend
tierDescription={plan.tierDescription} // "Basic" | "Recommended" | "Premium"
monthlyPrice={plan.monthlyPrice} // Direct from Salesforce PricebookEntry
/>
))}
// Add-ons are properly typed and organized
{addons
.filter(addon => addon.type === 'hikari-denwa-service')
.map(addon => (
<AddonCard
key={addon.id}
name={addon.name}
monthlyPrice={addon.monthlyPrice} // Direct from Salesforce
autoAdd={addon.autoAdd} // From Salesforce Auto_Add__c field
requiredWith={addon.requiredWith} // From Required_Products__c field
/>
))}
// Installation options - no guessing needed
{installations.map(inst => (
<InstallationOption
key={inst.id}
type={inst.type} // 'One-time' | '12-Month' | '24-Month'
price={inst.price} // Direct from Salesforce PricebookEntry
billingCycle={inst.billingCycle} // From Salesforce Billing_Cycle__c
/>
))}
```
## 🔄 **API Response Structure**
### Old Response (Bad)
```json
{
"internet": [
{"id": "1", "name": "Internet Gold Plan (Home 1G)", "sku": "INTERNET-GOLD-HOME-1G"},
{"id": "2", "name": "Internet Silver Plan (Home 1G)", "sku": "INTERNET-SILVER-HOME-1G"}
]
}
```
*Frontend has to guess everything from names/SKUs! 😞*
### New Response (Good)
```json
{
"internet": {
"plans": [
{
"id": "1",
"name": "Internet Gold Plan (Home 1G)",
"sku": "INTERNET-GOLD-HOME-1G",
"tier": "Gold",
"offeringType": "Home 1G",
"monthlyPrice": 6500,
"description": "Complete solution with v6plus router included",
"features": [
"1 NTT Wireless Home Gateway Router (v6plus compatible)",
"1 SonixNet ISP (IPoE-HGW) Activation + Monthly",
"Professional setup included"
],
"tierDescription": "Recommended",
"isRecommended": true
}
],
"installations": [
{
"id": "10",
"name": "NTT Installation Fee (12-Month Plan)",
"sku": "INTERNET-INSTALL-12M",
"type": "12-Month",
"price": 1900,
"billingCycle": "Monthly",
"description": "NTT Installation Fee (12-Month Plan)"
}
],
"addons": [
{
"id": "20",
"name": "Hikari Denwa (Home Phone)",
"sku": "INTERNET-ADDON-HOME-PHONE",
"type": "hikari-denwa-service",
"monthlyPrice": 450,
"autoAdd": false,
"requiredWith": ["INTERNET-ADDON-HIKARI-DENWA-INSTALL"]
},
{
"id": "21",
"name": "Hikari Denwa Installation Fee",
"sku": "INTERNET-ADDON-HIKARI-DENWA-INSTALL",
"type": "hikari-denwa-installation",
"activationPrice": 1000,
"autoAdd": true,
"requiredWith": []
}
]
}
}
```
*Everything pre-organized! Frontend just displays! 🎉*
## 🎯 **Benefits of Structured Approach**
### 1. **No Name Matching**
- ❌ `plan.name.includes('gold')`
- ✅ `plan.tier === 'Gold'`
### 2. **No SKU Parsing**
- ❌ `addon.sku.includes('HIKARI-DENWA')`
- ✅ `addon.type === 'hikari-denwa-service'`
### 3. **Business Logic in Right Place**
- ❌ Frontend calculating features based on tier names
- ✅ Backend provides pre-computed `plan.features` array
### 4. **Salesforce Field Mapping**
- ❌ Frontend guessing product types
- ✅ Backend uses actual `Internet_Plan_Tier__c`, `Addon_Type__c`, `Installation_Type__c` fields
### 5. **Extensible Without Code Changes**
- ❌ New product types break name matching
- ✅ New Salesforce fields automatically supported
### 6. **Type Safety**
- ❌ String matching is error-prone
- ✅ Strongly typed enums: `'Silver' | 'Gold' | 'Platinum'`
## 🚀 **Implementation Steps**
1. **Add Salesforce Fields** (if missing):
```sql
-- Custom fields for proper categorization
Internet_Plan_Tier__c (Picklist): Silver | Gold | Platinum
Addon_Type__c (Text): Hikari Denwa Service | Hikari Denwa Installation
Installation_Type__c (Picklist): One-time | 12-Month | 24-Month
```
2. **Use New Backend Endpoint**:
```typescript
// Replace this:
const catalog = await api.get("/catalog");
// With this:
const catalog = await api.get("/catalog/structured");
```
3. **Update Frontend Components**:
```typescript
// Remove all name matching logic
// Use structured data properties instead
// Old: plan.name?.toLowerCase().includes('gold')
// New: plan.tier === 'Gold'
```
4. **Add New Salesforce Fields to Field Mapping**:
```typescript
// In field-map.ts
internetPlanTier: "Internet_Plan_Tier__c",
addonType: "Addon_Type__c",
installationType: "Installation_Type__c"
```
This approach makes the frontend much simpler, more reliable, and fully leverages Salesforce's structured data instead of relying on fragile name matching!

9789
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff