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:
parent
6c7b1b531c
commit
4f087d5c4c
22
README.md
22
README.md
@ -6,8 +6,8 @@ A modern customer portal where users can self-register, log in, browse & buy sub
|
||||
|
||||
### Systems of Record
|
||||
|
||||
- **WHMCS**: Billing, subscriptions, and invoices
|
||||
- **Salesforce**: CRM (Accounts, Contacts, Cases)
|
||||
- **WHMCS**: Billing, subscriptions, invoices, and **authoritative address storage**
|
||||
- **Salesforce**: CRM (Accounts, Contacts, Cases) and **order address snapshots**
|
||||
- **Portal**: Modern UI with backend for frontend (BFF) architecture
|
||||
|
||||
### Identity Management
|
||||
@ -374,6 +374,24 @@ rm -rf node_modules && pnpm install
|
||||
- Implement clean, minimal UI designs [[memory:6676820]]
|
||||
- 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
|
||||
|
||||
[Your License Here]
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
SalesforceOrderItem,
|
||||
SalesforceQueryResult,
|
||||
} from "../types/salesforce-order.types";
|
||||
import { getSalesforceFieldMap } from "../../common/config/field-map";
|
||||
|
||||
/**
|
||||
* Main orchestrator for order operations
|
||||
@ -97,8 +98,9 @@ export class OrderOrchestrator {
|
||||
async getOrder(orderId: string) {
|
||||
this.logger.log({ orderId }, "Fetching order details with items");
|
||||
|
||||
const fields = getSalesforceFieldMap();
|
||||
const orderSoql = `
|
||||
SELECT Id, OrderNumber, Status, Order_Type__c, EffectiveDate, TotalAmount,
|
||||
SELECT Id, OrderNumber, Status, ${fields.order.orderType}, EffectiveDate, TotalAmount,
|
||||
Account.Name, CreatedDate, LastModifiedDate,
|
||||
Activation_Type__c, Activation_Status__c, Activation_Scheduled_At__c,
|
||||
WHMCS_Order_ID__c
|
||||
@ -158,7 +160,7 @@ export class OrderOrchestrator {
|
||||
id: order.Id,
|
||||
orderNumber: order.OrderNumber,
|
||||
status: order.Status,
|
||||
orderType: order.Order_Type__c,
|
||||
orderType: order.Type,
|
||||
effectiveDate: order.EffectiveDate,
|
||||
totalAmount: order.TotalAmount,
|
||||
accountName: order.Account?.Name,
|
||||
@ -185,8 +187,9 @@ export class OrderOrchestrator {
|
||||
// Get user mapping
|
||||
const userMapping = await this.orderValidator.validateUserMapping(userId);
|
||||
|
||||
const fields = getSalesforceFieldMap();
|
||||
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
|
||||
FROM Order
|
||||
WHERE AccountId = '${userMapping.sfAccountId}'
|
||||
@ -248,7 +251,7 @@ export class OrderOrchestrator {
|
||||
id: order.Id,
|
||||
orderNumber: order.OrderNumber,
|
||||
status: order.Status,
|
||||
orderType: order.Order_Type__c,
|
||||
orderType: order.Type,
|
||||
effectiveDate: order.EffectiveDate,
|
||||
totalAmount: order.TotalAmount,
|
||||
createdDate: order.CreatedDate,
|
||||
|
||||
@ -4,7 +4,7 @@ export interface SalesforceOrder {
|
||||
Id: string;
|
||||
OrderNumber: string;
|
||||
Status: string;
|
||||
Order_Type__c: string;
|
||||
Type: string;
|
||||
EffectiveDate: string;
|
||||
TotalAmount: number;
|
||||
Account: {
|
||||
@ -61,7 +61,7 @@ export interface OrderCreateRequest {
|
||||
AccountId: string;
|
||||
Status: string;
|
||||
EffectiveDate: string;
|
||||
Order_Type__c: string;
|
||||
Type: string;
|
||||
Activation_Type__c?: string;
|
||||
Activation_Scheduled_At__c?: string;
|
||||
WHMCS_Order_ID__c?: string;
|
||||
|
||||
@ -637,13 +637,25 @@ export class UsersService {
|
||||
whmcsUpdateData.companyname = billingData.company;
|
||||
}
|
||||
|
||||
// Update in WHMCS
|
||||
// Update in WHMCS (authoritative source)
|
||||
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdateData);
|
||||
|
||||
this.logger.log({ userId }, "Successfully updated billing information in WHMCS");
|
||||
} catch (error) {
|
||||
this.logger.error({ userId, error }, "Failed to update billing information");
|
||||
throw error;
|
||||
|
||||
// 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
@ -30,10 +30,39 @@ interface BillingInfo {
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
function BillingHeading() {
|
||||
const searchParams = useSearchParams();
|
||||
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 [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@ -48,7 +77,7 @@ export default function BillingPage() {
|
||||
const fetchBillingInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
|
||||
setBillingInfo(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load billing information");
|
||||
@ -93,7 +122,7 @@ export default function BillingPage() {
|
||||
setError(null);
|
||||
|
||||
// Update address via API
|
||||
await authenticatedApi.patch("/users/billing", {
|
||||
await authenticatedApi.patch("/me/billing", {
|
||||
street: editedAddress.street,
|
||||
streetLine2: editedAddress.streetLine2,
|
||||
city: editedAddress.city,
|
||||
@ -146,29 +175,16 @@ export default function BillingPage() {
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<CreditCardIcon className="h-8 w-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{isCompletionFlow ? "Complete Your Profile" : "Billing & Address"}
|
||||
</h1>
|
||||
<Suspense
|
||||
fallback={<h1 className="text-3xl font-bold text-gray-900">Billing & Address</h1>}
|
||||
>
|
||||
<BillingHeading />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{isCompletionFlow && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6 mb-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">
|
||||
Profile Completion Required
|
||||
</h3>
|
||||
<p className="text-blue-800">
|
||||
Please review and complete your address information to access all features and
|
||||
enable service ordering.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
<BillingCompletionBanner />
|
||||
</Suspense>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
|
||||
@ -3,8 +3,44 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
import { authenticatedApi } from "@/lib/api";
|
||||
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)
|
||||
interface EnhancedUser {
|
||||
@ -14,52 +50,88 @@ interface EnhancedUser {
|
||||
lastName?: string;
|
||||
company?: 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
|
||||
// salesforceAccountId?: string;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuthStore();
|
||||
const enhancedUser = user as EnhancedUser | null;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isEditingAddress, setIsEditingAddress] = 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({
|
||||
firstName: user?.firstName || "",
|
||||
lastName: user?.lastName || "",
|
||||
email: user?.email || "",
|
||||
company: enhancedUser?.company || user?.company || "",
|
||||
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 = () => {
|
||||
setIsEditing(true);
|
||||
setFormData({
|
||||
firstName: user?.firstName || "",
|
||||
lastName: user?.lastName || "",
|
||||
email: user?.email || "",
|
||||
company: enhancedUser?.company || user?.company || "",
|
||||
phone: user?.phone || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditAddress = () => {
|
||||
setIsEditingAddress(true);
|
||||
if (billingInfo?.address) {
|
||||
setAddressData(billingInfo.address);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setFormData({
|
||||
firstName: user?.firstName || "",
|
||||
lastName: user?.lastName || "",
|
||||
email: user?.email || "",
|
||||
company: enhancedUser?.company || user?.company || "",
|
||||
phone: user?.phone || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelAddress = () => {
|
||||
setIsEditingAddress(false);
|
||||
if (billingInfo?.address) {
|
||||
setAddressData(billingInfo.address);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
@ -79,7 +151,6 @@ export default function ProfilePage() {
|
||||
body: JSON.stringify({
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
company: formData.company,
|
||||
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) => {
|
||||
setFormData(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)
|
||||
useEffect(() => {
|
||||
if (user && !isEditing) {
|
||||
@ -119,11 +244,25 @@ export default function ProfilePage() {
|
||||
firstName: user.firstName || "",
|
||||
lastName: user.lastName || "",
|
||||
email: user.email || "",
|
||||
company: enhancedUser?.company || user.company || "",
|
||||
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 (
|
||||
<DashboardLayout>
|
||||
@ -132,42 +271,55 @@ export default function ProfilePage() {
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<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">
|
||||
{user?.firstName?.[0]?.toUpperCase() || user?.email?.[0]?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</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.firstName
|
||||
: user?.email || "User Profile"}
|
||||
: "Profile"}
|
||||
</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">
|
||||
Manage your account information and preferences
|
||||
Manage your personal information and address
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<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 space-x-3">
|
||||
<UserIcon className="h-5 w-5 text-gray-400" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Personal Information</h3>
|
||||
<UserIcon className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
@ -175,7 +327,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
@ -186,7 +338,7 @@ export default function ProfilePage() {
|
||||
type="text"
|
||||
value={formData.firstName}
|
||||
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">
|
||||
@ -207,7 +359,7 @@ export default function ProfilePage() {
|
||||
type="text"
|
||||
value={formData.lastName}
|
||||
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">
|
||||
@ -220,39 +372,28 @@ export default function ProfilePage() {
|
||||
|
||||
{/* Email */}
|
||||
<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
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm text-gray-900 py-2 flex-1">{user?.email}</p>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Verified
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Email cannot be changed. Contact support if you need to update your email
|
||||
address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Company</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
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>
|
||||
)}
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-base text-gray-900 font-medium">{user?.email}</p>
|
||||
<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">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Verified
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Email cannot be changed. Contact support if you need to update your email
|
||||
address.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
@ -266,7 +407,7 @@ export default function ProfilePage() {
|
||||
value={formData.phone}
|
||||
onChange={e => handleInputChange("phone", e.target.value)}
|
||||
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">
|
||||
@ -311,32 +452,213 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Status */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Account Status</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<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>
|
||||
{/* Address 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 space-x-3">
|
||||
<MapPinIcon className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Account Status</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-green-100 text-green-800">
|
||||
Active
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
{/* Internal data sources are not shown to users */}
|
||||
{!isEditingAddress && (
|
||||
<button
|
||||
onClick={handleEditAddress}
|
||||
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-2" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@ -21,6 +21,15 @@ interface OrderItem {
|
||||
};
|
||||
}
|
||||
|
||||
interface StatusInfo {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
description: string;
|
||||
nextAction?: string;
|
||||
timeline?: string;
|
||||
}
|
||||
|
||||
interface OrderSummary {
|
||||
id: string;
|
||||
orderNumber?: string;
|
||||
@ -38,6 +47,100 @@ interface OrderSummary {
|
||||
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'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'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'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() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
@ -68,8 +171,10 @@ export default function OrderStatusPage() {
|
||||
return (
|
||||
<PageLayout
|
||||
icon={<ClipboardDocumentCheckIcon />}
|
||||
title={`Order ${data?.orderNumber || params.id}`}
|
||||
description="We'll update this page as your order progresses"
|
||||
title={data ? `${data.orderType} Service Order` : "Order Details"}
|
||||
description={
|
||||
data ? `Order #${data.orderNumber || data.id.slice(-8)}` : "Loading order details..."
|
||||
}
|
||||
>
|
||||
{error && <div className="text-red-600 text-sm mb-4">{error}</div>}
|
||||
|
||||
@ -102,104 +207,321 @@ export default function OrderStatusPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Header Information */}
|
||||
<div className="bg-white border rounded-xl p-6 mb-6">
|
||||
<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">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Order Status:</span>{" "}
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-sm ${
|
||||
data?.status === "Draft"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: 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>
|
||||
{/* Service Overview */}
|
||||
{data &&
|
||||
(() => {
|
||||
const statusInfo = getDetailedStatusInfo(
|
||||
data.status,
|
||||
data.activationStatus,
|
||||
data.activationType,
|
||||
data.scheduledAt
|
||||
);
|
||||
const serviceIcon = getServiceTypeIcon(data.orderType);
|
||||
|
||||
{/* Order Items */}
|
||||
{data?.items && data.items.length > 0 && (
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Order Items</h2>
|
||||
<div className="space-y-4">
|
||||
{data.items.map(item => (
|
||||
<div key={item.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{item.product.name}</h3>
|
||||
<div className="text-sm text-gray-600 space-y-1 mt-1">
|
||||
<div>
|
||||
<span className="font-medium">SKU:</span> {item.product.sku}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Item Class:</span> {item.product.itemClass}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Billing Cycle:</span>{" "}
|
||||
{item.product.billingCycle}
|
||||
</div>
|
||||
{item.product.whmcsProductId && (
|
||||
<div>
|
||||
<span className="font-medium">WHMCS Product ID:</span>{" "}
|
||||
{item.product.whmcsProductId}
|
||||
return (
|
||||
<div className="bg-white border rounded-2xl p-8 mb-8">
|
||||
{/* Service Header */}
|
||||
<div className="flex items-start gap-6 mb-6">
|
||||
<div className="text-4xl">{serviceIcon}</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{data.orderType} Service
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Order #{data.orderNumber || data.id.slice(-8)} • Placed{" "}
|
||||
{new Date(data.createdDate).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.items &&
|
||||
data.items.length > 0 &&
|
||||
(() => {
|
||||
const totals = calculateDetailedTotals(data.items);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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 className="text-right ml-4">
|
||||
<div className="font-medium text-gray-900">Qty: {item.quantity}</div>
|
||||
{item.unitPrice && (
|
||||
<div className="text-sm text-gray-600">
|
||||
¥{item.unitPrice.toLocaleString()} each
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className={`text-xl font-bold ${statusInfo.color} mb-2`}>
|
||||
{statusInfo.label}
|
||||
</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>
|
||||
)}
|
||||
{item.totalPrice && (
|
||||
<div className="font-semibold text-lg text-gray-900">
|
||||
¥{item.totalPrice.toLocaleString()}
|
||||
</div>
|
||||
|
||||
{statusInfo.timeline && (
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Timeline:</span> {statusInfo.timeline}
|
||||
</p>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -24,6 +24,14 @@ interface OrderSummary {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface StatusInfo {
|
||||
label: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
description: string;
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
function OrdersSuccessBanner() {
|
||||
const searchParams = useSearchParams();
|
||||
const showSuccess = searchParams.get("status") === "success";
|
||||
@ -66,17 +74,81 @@ export default function OrdersPage() {
|
||||
void fetchOrders();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "Activated":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "Pending Review":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
const getStatusInfo = (status: string, activationStatus?: string): StatusInfo => {
|
||||
// Combine order status and activation status for better UX
|
||||
if (status === "Activated") {
|
||||
return {
|
||||
label: "Active",
|
||||
color: "text-green-800",
|
||||
bgColor: "bg-green-100",
|
||||
description: "Your service is active and ready to use",
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
@ -114,56 +186,112 @@ export default function OrdersPage() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{orders.map(order => (
|
||||
<div
|
||||
key={order.id}
|
||||
className="bg-white border rounded-xl p-6 hover:shadow-md transition-shadow cursor-pointer"
|
||||
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="space-y-6">
|
||||
{orders.map(order => {
|
||||
const statusInfo = getStatusInfo(order.status, order.activationStatus);
|
||||
const serviceType = getServiceTypeDisplay(order.orderType);
|
||||
const serviceSummary = getServiceSummary(order);
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Items:</span>
|
||||
<p className="text-gray-600">
|
||||
{order.itemSummary ||
|
||||
(order.itemsSummary && order.itemsSummary.length > 0
|
||||
? order.itemsSummary
|
||||
.map(item => `${item.name} (${item.quantity})`)
|
||||
.join(", ")
|
||||
: "No items")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Activation Status:</span>
|
||||
<p className="text-gray-600">{order.activationStatus || "Not Started"}</p>
|
||||
</div>
|
||||
{order.totalAmount && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Total:</span>
|
||||
<p className="text-gray-900 font-semibold">
|
||||
¥{order.totalAmount.toLocaleString()}
|
||||
</p>
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
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"
|
||||
onClick={() => router.push(`/orders/${order.id}`)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="text-2xl">{serviceType.icon}</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||
{serviceType.label}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Order #{order.orderNumber || order.id.slice(-8)} •{" "}
|
||||
{new Date(order.createdDate).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
</PageLayout>
|
||||
|
||||
@ -51,7 +51,7 @@ export function AddressConfirmation({
|
||||
const fetchBillingInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||
const data = await authenticatedApi.get<BillingInfo>("/me/billing");
|
||||
setBillingInfo(data);
|
||||
|
||||
// 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>
|
||||
</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.
|
||||
Please verify this is the correct address for your internet installation. A
|
||||
technician will visit this location for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -42,7 +42,6 @@ const navigation = [
|
||||
name: "Billing",
|
||||
icon: CreditCardIcon,
|
||||
children: [
|
||||
{ name: "Address & Info", href: "/account/billing" },
|
||||
{ name: "Invoices", href: "/billing/invoices" },
|
||||
{ name: "Payment Methods", href: "/billing/payments" },
|
||||
],
|
||||
|
||||
@ -35,7 +35,7 @@ export function useProfileCompletion(): ProfileCompletionStatus {
|
||||
useEffect(() => {
|
||||
const checkProfileCompletion = async () => {
|
||||
try {
|
||||
const billingInfo = await authenticatedApi.get<BillingInfo>("/users/billing");
|
||||
const billingInfo = await authenticatedApi.get<BillingInfo>("/me/billing");
|
||||
setIsComplete(billingInfo.isComplete);
|
||||
} catch (error) {
|
||||
console.error("Failed to check profile completion:", error);
|
||||
|
||||
15
apps/portal/src/types/world-countries.d.ts
vendored
Normal file
15
apps/portal/src/types/world-countries.d.ts
vendored
Normal 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
351
docs/ADDRESS_SYSTEM.md
Normal 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.*
|
||||
@ -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! 🎯
|
||||
@ -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**! 🎯
|
||||
@ -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
|
||||
@ -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**! 🚀
|
||||
@ -1,4 +1,4 @@
|
||||
# 🚀 Getting Started with Customer Portal
|
||||
# 🚀 Getting Started
|
||||
|
||||
## ✅ **Environment File Options**
|
||||
|
||||
|
||||
@ -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.
|
||||
167
docs/README.md
167
docs/README.md
@ -2,136 +2,65 @@
|
||||
|
||||
## 🚀 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
|
||||
# Copy development environment template
|
||||
cp .env.dev.example .env
|
||||
# Edit .env with your development values
|
||||
## 🏗️ Architecture & Systems
|
||||
|
||||
# Start development
|
||||
pnpm dev:start # Start services (PostgreSQL + Redis)
|
||||
pnpm dev # Start apps with hot reload
|
||||
```
|
||||
- **[Address System](ADDRESS_SYSTEM.md)** - Complete address management documentation
|
||||
- **[Product Catalog Architecture](PRODUCT-CATALOG-ARCHITECTURE.md)** - SKU-based catalog system
|
||||
- **[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
|
||||
# Configure for production
|
||||
cp .env.production.example .env
|
||||
# Edit .env with production values
|
||||
- **[Logging](LOGGING.md)** - Logging standards and practices
|
||||
- **[Salesforce Products](SALESFORCE-PRODUCTS.md)** - Product configuration in Salesforce
|
||||
- **[Portal Roadmap](PORTAL-ROADMAP.md)** - Future development plans
|
||||
|
||||
# Deploy to production
|
||||
pnpm prod:deploy
|
||||
```
|
||||
## 📋 Documentation Standards
|
||||
|
||||
## 📁 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
|
||||
├── 🚀 apps/ # Applications
|
||||
│ ├── portal/ # Next.js frontend
|
||||
│ └── bff/ # NestJS backend
|
||||
├── 🐳 docker/ # Docker configurations
|
||||
│ ├── dev/ # Development (services only)
|
||||
│ └── prod/ # Production (complete stack)
|
||||
├── 🛠️ scripts/ # Management scripts
|
||||
│ ├── dev/manage.sh # Development manager
|
||||
│ ├── prod/manage.sh # Production manager
|
||||
│ └── plesk-deploy.sh # Plesk deployment
|
||||
├── 📚 docs/ # Documentation
|
||||
├── 📦 packages/shared/ # Shared utilities
|
||||
└── 🔧 Configuration files
|
||||
docs/
|
||||
├── README.md # This index
|
||||
├── GETTING_STARTED.md # Setup & configuration
|
||||
├── RUN.md # Development commands
|
||||
├── DEPLOY.md # Production deployment
|
||||
├── ADDRESS_SYSTEM.md # Address management
|
||||
├── PRODUCT-CATALOG-ARCHITECTURE.md # Catalog system
|
||||
├── PORTAL-DATA-MODEL.md # Database schema
|
||||
├── PORTAL-ORDERING-PROVISIONING.md # Order processing
|
||||
├── STRUCTURE.md # System architecture
|
||||
├── LOGGING.md # Logging practices
|
||||
├── SALESFORCE-PRODUCTS.md # SF configuration
|
||||
└── PORTAL-ROADMAP.md # Future plans
|
||||
```
|
||||
|
||||
## 🔧 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`
|
||||
- **Development database**: PostgreSQL in Docker
|
||||
- **Cache**: Redis in Docker
|
||||
- **Apps**: Run locally with hot reload
|
||||
### Developer Experience
|
||||
- ✅ **Quick reference** - Essential info easily accessible
|
||||
- ✅ **Complete examples** - Working code snippets
|
||||
- ✅ **Troubleshooting** - Common issues and solutions
|
||||
- ✅ **Testing guidance** - How to verify implementations
|
||||
|
||||
### Commands
|
||||
---
|
||||
|
||||
```bash
|
||||
# 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**! 🚀
|
||||
*This documentation reflects the current clean, modern implementation of the Customer Portal system.*
|
||||
|
||||
@ -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! 🎉
|
||||
@ -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
9789
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user