Refactor WHMCS integration to remove admin authentication and enhance API request handling
- Removed admin authentication methods and related configurations from WhmcsConfigService, simplifying the API integration. - Updated WHMCS API methods to directly use API key authentication, improving security and reducing complexity. - Enhanced error logging in WHMCS HTTP client to include full response context for better debugging. - Adjusted order processing components to reflect changes in authentication handling, ensuring consistent behavior across the application. - Improved loading states in the portal UI for better user experience during order processing.
This commit is contained in:
parent
e1f3160145
commit
b3e3689315
@ -37,26 +37,6 @@ export class WhmcsConfigService {
|
|||||||
return this.config.baseUrl;
|
return this.config.baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if admin authentication is available
|
|
||||||
*/
|
|
||||||
hasAdminAuth(): boolean {
|
|
||||||
return Boolean(this.config.adminUsername && this.config.adminPasswordHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get admin authentication credentials
|
|
||||||
*/
|
|
||||||
getAdminAuth(): { username: string; passwordHash: string } | null {
|
|
||||||
if (!this.hasAdminAuth()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
username: this.config.adminUsername!,
|
|
||||||
passwordHash: this.config.adminPasswordHash!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that required configuration is present
|
* Validate that required configuration is present
|
||||||
*/
|
*/
|
||||||
@ -91,17 +71,6 @@ export class WhmcsConfigService {
|
|||||||
const secret =
|
const secret =
|
||||||
this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || "";
|
this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || "";
|
||||||
|
|
||||||
const adminUsername = this.getFirst([
|
|
||||||
isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined,
|
|
||||||
"WHMCS_ADMIN_USERNAME",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const adminPasswordHash = this.getFirst([
|
|
||||||
isDev ? "WHMCS_DEV_ADMIN_PASSWORD_MD5" : undefined,
|
|
||||||
"WHMCS_ADMIN_PASSWORD_MD5",
|
|
||||||
"WHMCS_ADMIN_PASSWORD_HASH",
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
identifier,
|
identifier,
|
||||||
@ -109,8 +78,6 @@ export class WhmcsConfigService {
|
|||||||
timeout: this.getNumberConfig("WHMCS_API_TIMEOUT", 30000),
|
timeout: this.getNumberConfig("WHMCS_API_TIMEOUT", 30000),
|
||||||
retryAttempts: this.getNumberConfig("WHMCS_API_RETRY_ATTEMPTS", 3),
|
retryAttempts: this.getNumberConfig("WHMCS_API_RETRY_ATTEMPTS", 3),
|
||||||
retryDelay: this.getNumberConfig("WHMCS_API_RETRY_DELAY", 1000),
|
retryDelay: this.getNumberConfig("WHMCS_API_RETRY_DELAY", 1000),
|
||||||
adminUsername,
|
|
||||||
adminPasswordHash,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,35 +177,29 @@ export class WhmcsApiMethodsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// ADMIN API METHODS (require admin auth)
|
// ADMIN API METHODS (require admin permissions on API key)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept and provision an order
|
||||||
|
* Note: Your API key must have AcceptOrder permission granted
|
||||||
|
*/
|
||||||
async acceptOrder(orderId: number): Promise<{ result: string }> {
|
async acceptOrder(orderId: number): Promise<{ result: string }> {
|
||||||
if (!this.configService.hasAdminAuth()) {
|
return this.makeRequest<{ result: string }>("AcceptOrder", {
|
||||||
throw new Error("Admin authentication required for AcceptOrder");
|
orderid: orderId.toString(),
|
||||||
}
|
autosetup: true,
|
||||||
|
sendemail: false,
|
||||||
return this.makeRequest<{ result: string }>(
|
});
|
||||||
"AcceptOrder",
|
|
||||||
{
|
|
||||||
orderid: orderId.toString(),
|
|
||||||
autosetup: true,
|
|
||||||
sendemail: false,
|
|
||||||
},
|
|
||||||
{ useAdminAuth: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel an order
|
||||||
|
* Note: Your API key must have CancelOrder permission granted
|
||||||
|
*/
|
||||||
async cancelOrder(orderId: number): Promise<{ result: string }> {
|
async cancelOrder(orderId: number): Promise<{ result: string }> {
|
||||||
if (!this.configService.hasAdminAuth()) {
|
return this.makeRequest<{ result: string }>("CancelOrder", {
|
||||||
throw new Error("Admin authentication required for CancelOrder");
|
orderid: orderId,
|
||||||
}
|
});
|
||||||
|
|
||||||
return this.makeRequest<{ result: string }>(
|
|
||||||
"CancelOrder",
|
|
||||||
{ orderid: orderId },
|
|
||||||
{ useAdminAuth: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -334,7 +334,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
|||||||
timeout: config.timeout,
|
timeout: config.timeout,
|
||||||
retryAttempts: config.retryAttempts,
|
retryAttempts: config.retryAttempts,
|
||||||
retryDelay: config.retryDelay,
|
retryDelay: config.retryDelay,
|
||||||
hasAdminAuth: this.configService.hasAdminAuth(),
|
|
||||||
hasAccessKey: Boolean(this.configService.getAccessKey()),
|
hasAccessKey: Boolean(this.configService.getAccessKey()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,6 +165,7 @@ export class WhmcsHttpClientService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build request body for WHMCS API
|
* Build request body for WHMCS API
|
||||||
|
* Uses API key authentication (identifier + secret)
|
||||||
*/
|
*/
|
||||||
private buildRequestBody(
|
private buildRequestBody(
|
||||||
config: WhmcsApiConfig,
|
config: WhmcsApiConfig,
|
||||||
@ -174,14 +175,9 @@ export class WhmcsHttpClientService {
|
|||||||
): string {
|
): string {
|
||||||
const formData = new URLSearchParams();
|
const formData = new URLSearchParams();
|
||||||
|
|
||||||
// Add authentication
|
// Add API key authentication
|
||||||
if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) {
|
formData.append("identifier", config.identifier);
|
||||||
formData.append("username", config.adminUsername);
|
formData.append("secret", config.secret);
|
||||||
formData.append("password", config.adminPasswordHash);
|
|
||||||
} else {
|
|
||||||
formData.append("identifier", config.identifier);
|
|
||||||
formData.append("secret", config.secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add action and response format
|
// Add action and response format
|
||||||
formData.append("action", action);
|
formData.append("action", action);
|
||||||
@ -275,13 +271,23 @@ export class WhmcsHttpClientService {
|
|||||||
);
|
);
|
||||||
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
|
const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown");
|
||||||
|
|
||||||
|
// Extract all additional fields from the response for debugging
|
||||||
|
const { result, message, error, errorcode, ...additionalFields } = parsedResponse;
|
||||||
|
|
||||||
this.logger.error(`WHMCS API returned error [${action}]`, {
|
this.logger.error(`WHMCS API returned error [${action}]`, {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
errorCode,
|
errorCode,
|
||||||
|
additionalFields: Object.keys(additionalFields).length > 0 ? additionalFields : undefined,
|
||||||
params: this.sanitizeLogParams(params),
|
params: this.sanitizeLogParams(params),
|
||||||
|
fullResponse: parsedResponse,
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`);
|
// Include full context in the error for better debugging
|
||||||
|
const errorContext = Object.keys(additionalFields).length > 0
|
||||||
|
? ` | Additional details: ${JSON.stringify(additionalFields)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For successful responses, WHMCS API returns data directly at the root level
|
// For successful responses, WHMCS API returns data directly at the root level
|
||||||
|
|||||||
@ -5,13 +5,9 @@ export interface WhmcsApiConfig {
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
retryAttempts?: number;
|
retryAttempts?: number;
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
// Optional elevated admin credentials for privileged actions (eg. AcceptOrder)
|
|
||||||
adminUsername?: string;
|
|
||||||
adminPasswordHash?: string; // MD5 hash of admin password
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhmcsRequestOptions {
|
export interface WhmcsRequestOptions {
|
||||||
useAdminAuth?: boolean;
|
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
retryAttempts?: number;
|
retryAttempts?: number;
|
||||||
retryDelay?: number;
|
retryDelay?: number;
|
||||||
|
|||||||
@ -25,6 +25,10 @@ export class WhmcsOrderService {
|
|||||||
/**
|
/**
|
||||||
* Create order in WHMCS using AddOrder API
|
* Create order in WHMCS using AddOrder API
|
||||||
* Maps Salesforce OrderItems to WHMCS products
|
* Maps Salesforce OrderItems to WHMCS products
|
||||||
|
*
|
||||||
|
* WHMCS API Response Structure:
|
||||||
|
* Success: { orderid, productids, serviceids, addonids, domainids, invoiceid }
|
||||||
|
* Error: Thrown by HTTP client before returning
|
||||||
*/
|
*/
|
||||||
async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> {
|
async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> {
|
||||||
this.logger.log("Creating WHMCS order", {
|
this.logger.log("Creating WHMCS order", {
|
||||||
@ -38,18 +42,40 @@ export class WhmcsOrderService {
|
|||||||
// Build WHMCS AddOrder payload
|
// Build WHMCS AddOrder payload
|
||||||
const addOrderPayload = this.buildAddOrderPayload(params);
|
const addOrderPayload = this.buildAddOrderPayload(params);
|
||||||
|
|
||||||
|
this.logger.debug("Built WHMCS AddOrder payload", {
|
||||||
|
clientId: params.clientId,
|
||||||
|
productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0,
|
||||||
|
pids: addOrderPayload.pid,
|
||||||
|
quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added
|
||||||
|
billingCycles: addOrderPayload.billingcycle,
|
||||||
|
hasConfigOptions: Boolean(addOrderPayload.configoptions),
|
||||||
|
hasCustomFields: Boolean(addOrderPayload.customfields),
|
||||||
|
promoCode: addOrderPayload.promocode,
|
||||||
|
paymentMethod: addOrderPayload.paymentmethod,
|
||||||
|
});
|
||||||
|
|
||||||
// Call WHMCS AddOrder API
|
// Call WHMCS AddOrder API
|
||||||
|
// Note: The HTTP client throws errors automatically if result === "error"
|
||||||
|
// So we only get here if the request was successful
|
||||||
const response = (await this.connection.addOrder(addOrderPayload)) as Record<string, unknown>;
|
const response = (await this.connection.addOrder(addOrderPayload)) as Record<string, unknown>;
|
||||||
|
|
||||||
if (response.result !== "success") {
|
// Log the full response for debugging
|
||||||
throw new WhmcsOperationException(
|
this.logger.debug("WHMCS AddOrder response", {
|
||||||
`WHMCS AddOrder failed: ${(response.message as string) || "Unknown error"}`,
|
response,
|
||||||
{ params }
|
clientId: params.clientId,
|
||||||
);
|
sfOrderId: params.sfOrderId,
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// Extract order ID from response
|
||||||
const orderId = parseInt(response.orderid as string, 10);
|
const orderId = parseInt(response.orderid as string, 10);
|
||||||
if (!orderId) {
|
if (!orderId || isNaN(orderId)) {
|
||||||
|
this.logger.error("WHMCS AddOrder returned invalid order ID", {
|
||||||
|
response,
|
||||||
|
orderidValue: response.orderid,
|
||||||
|
orderidType: typeof response.orderid,
|
||||||
|
clientId: params.clientId,
|
||||||
|
sfOrderId: params.sfOrderId,
|
||||||
|
});
|
||||||
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
|
throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", {
|
||||||
response,
|
response,
|
||||||
});
|
});
|
||||||
@ -57,16 +83,23 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
this.logger.log("WHMCS order created successfully", {
|
this.logger.log("WHMCS order created successfully", {
|
||||||
orderId,
|
orderId,
|
||||||
|
invoiceId: response.invoiceid,
|
||||||
|
serviceIds: response.serviceids,
|
||||||
clientId: params.clientId,
|
clientId: params.clientId,
|
||||||
sfOrderId: params.sfOrderId,
|
sfOrderId: params.sfOrderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { orderId };
|
return { orderId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Enhanced error logging with full context
|
||||||
this.logger.error("Failed to create WHMCS order", {
|
this.logger.error("Failed to create WHMCS order", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
|
errorType: error?.constructor?.name,
|
||||||
clientId: params.clientId,
|
clientId: params.clientId,
|
||||||
sfOrderId: params.sfOrderId,
|
sfOrderId: params.sfOrderId,
|
||||||
|
itemCount: params.items.length,
|
||||||
|
// Include first 100 chars of error stack for debugging
|
||||||
|
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -75,6 +108,10 @@ export class WhmcsOrderService {
|
|||||||
/**
|
/**
|
||||||
* Accept/provision order in WHMCS using AcceptOrder API
|
* Accept/provision order in WHMCS using AcceptOrder API
|
||||||
* This activates services and creates subscriptions
|
* This activates services and creates subscriptions
|
||||||
|
*
|
||||||
|
* WHMCS API Response Structure:
|
||||||
|
* Success: { orderid, invoiceid, serviceids, addonids, domainids }
|
||||||
|
* Error: Thrown by HTTP client before returning
|
||||||
*/
|
*/
|
||||||
async acceptOrder(orderId: number, sfOrderId?: string): Promise<WhmcsOrderResult> {
|
async acceptOrder(orderId: number, sfOrderId?: string): Promise<WhmcsOrderResult> {
|
||||||
this.logger.log("Accepting WHMCS order", {
|
this.logger.log("Accepting WHMCS order", {
|
||||||
@ -84,14 +121,16 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Call WHMCS AcceptOrder API
|
// Call WHMCS AcceptOrder API
|
||||||
|
// Note: The HTTP client throws errors automatically if result === "error"
|
||||||
|
// So we only get here if the request was successful
|
||||||
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
|
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
|
||||||
|
|
||||||
if (response.result !== "success") {
|
// Log the full response for debugging
|
||||||
throw new WhmcsOperationException(
|
this.logger.debug("WHMCS AcceptOrder response", {
|
||||||
`WHMCS AcceptOrder failed: ${(response.message as string) || "Unknown error"}`,
|
response,
|
||||||
{ orderId, sfOrderId }
|
orderId,
|
||||||
);
|
sfOrderId,
|
||||||
}
|
});
|
||||||
|
|
||||||
// Extract service IDs from response
|
// Extract service IDs from response
|
||||||
const serviceIds: number[] = [];
|
const serviceIds: number[] = [];
|
||||||
@ -116,10 +155,14 @@ export class WhmcsOrderService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Enhanced error logging with full context
|
||||||
this.logger.error("Failed to accept WHMCS order", {
|
this.logger.error("Failed to accept WHMCS order", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
|
errorType: error?.constructor?.name,
|
||||||
orderId,
|
orderId,
|
||||||
sfOrderId,
|
sfOrderId,
|
||||||
|
// Include first 100 chars of error stack for debugging
|
||||||
|
errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -130,21 +173,16 @@ export class WhmcsOrderService {
|
|||||||
*/
|
*/
|
||||||
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> {
|
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> {
|
||||||
try {
|
try {
|
||||||
|
// Note: The HTTP client throws errors automatically if result === "error"
|
||||||
const response = (await this.connection.getOrders({
|
const response = (await this.connection.getOrders({
|
||||||
id: orderId.toString(),
|
id: orderId.toString(),
|
||||||
})) as Record<string, unknown>;
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
if (response.result !== "success") {
|
|
||||||
throw new WhmcsOperationException(
|
|
||||||
`WHMCS GetOrders failed: ${(response.message as string) || "Unknown error"}`,
|
|
||||||
{ orderId }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (response.orders as { order?: Record<string, unknown>[] })?.order?.[0] || null;
|
return (response.orders as { order?: Record<string, unknown>[] })?.order?.[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to get WHMCS order details", {
|
this.logger.error("Failed to get WHMCS order details", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
|
errorType: error?.constructor?.name,
|
||||||
orderId,
|
orderId,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -1,18 +1,115 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
||||||
import { ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline";
|
import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
|
||||||
import { LoadingCard } from "@/components/atoms/loading-skeleton";
|
import { Button } from "@/components/atoms/button";
|
||||||
|
|
||||||
export default function OrderDetailLoading() {
|
export default function OrderDetailLoading() {
|
||||||
return (
|
return (
|
||||||
<RouteLoading
|
<RouteLoading
|
||||||
icon={<ClipboardDocumentCheckIcon />}
|
icon={<ClipboardDocumentCheckIcon />}
|
||||||
title="Order Details"
|
title="Order Details"
|
||||||
description="Order status and items"
|
description="Loading order details..."
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="mb-6">
|
||||||
<LoadingCard />
|
<Button
|
||||||
<LoadingCard />
|
as="a"
|
||||||
|
href="/orders"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
|
||||||
|
className="text-gray-600 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Back to orders
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
{/* Left: Title & Status */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-8 w-48 rounded bg-slate-200" />
|
||||||
|
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-64 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Pricing Section */}
|
||||||
|
<div className="flex items-start gap-6 sm:gap-8">
|
||||||
|
<div className="text-right space-y-2">
|
||||||
|
<div className="h-3 w-16 rounded bg-slate-100" />
|
||||||
|
<div className="h-9 w-24 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right space-y-2">
|
||||||
|
<div className="h-3 w-16 rounded bg-slate-100" />
|
||||||
|
<div className="h-9 w-24 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body Section */}
|
||||||
|
<div className="px-6 py-6 sm:px-8">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Order Items Section */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Item 1 */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
||||||
|
<div className="flex flex-1 items-baseline justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-3/4 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-24 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="h-6 w-28 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item 2 */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
||||||
|
<div className="flex flex-1 items-baseline justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-2/3 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-20 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="h-6 w-24 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item 3 */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-1 items-start gap-3">
|
||||||
|
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
|
||||||
|
<div className="flex flex-1 items-baseline justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-1/2 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-16 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<div className="h-6 w-20 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,8 +10,8 @@ export default function OrdersLoading() {
|
|||||||
description="View and track all your orders"
|
description="View and track all your orders"
|
||||||
mode="content"
|
mode="content"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 6 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<OrderCardSkeleton key={idx} />
|
<OrderCardSkeleton key={idx} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
DevicePhoneMobileIcon,
|
DevicePhoneMobileIcon,
|
||||||
LockClosedIcon,
|
LockClosedIcon,
|
||||||
CubeIcon,
|
CubeIcon,
|
||||||
ChevronRightIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import {
|
import {
|
||||||
calculateOrderTotals,
|
calculateOrderTotals,
|
||||||
@ -71,14 +70,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
() => buildOrderDisplayItems(order.itemsSummary),
|
() => buildOrderDisplayItems(order.itemsSummary),
|
||||||
[order.itemsSummary]
|
[order.itemsSummary]
|
||||||
);
|
);
|
||||||
const serviceSummary = useMemo(
|
|
||||||
() =>
|
// Use just the order type as the service name
|
||||||
summarizeOrderDisplayItems(
|
const serviceName = order.orderType ? `${order.orderType} Service` : "Service Order";
|
||||||
displayItems,
|
|
||||||
order.itemSummary || "Service package"
|
|
||||||
),
|
|
||||||
[displayItems, order.itemSummary]
|
|
||||||
);
|
|
||||||
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
|
const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount);
|
||||||
const createdDate = useMemo(() => {
|
const createdDate = useMemo(() => {
|
||||||
if (!order.createdDate) return null;
|
if (!order.createdDate) return null;
|
||||||
@ -118,10 +113,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
role={isInteractive ? "button" : undefined}
|
role={isInteractive ? "button" : undefined}
|
||||||
tabIndex={isInteractive ? 0 : undefined}
|
tabIndex={isInteractive ? 0 : undefined}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<div className="px-6 py-4">
|
||||||
<div className="border-b border-slate-100 bg-gradient-to-br from-white to-slate-50 px-6 py-4">
|
<div className="flex items-start justify-between gap-6">
|
||||||
<div className="flex items-center justify-between gap-6">
|
{/* Left section: Icon + Service info + Status */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
|
||||||
@ -130,19 +125,34 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
>
|
>
|
||||||
{serviceIcon}
|
{serviceIcon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-gray-900">{serviceSummary}</h3>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
|
<h3 className="font-semibold text-gray-900">{serviceName}</h3>
|
||||||
|
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
|
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{formattedCreatedDate || "—"}</span>
|
<span>{formattedCreatedDate || "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{displayItems.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{displayItems.map(item => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing */}
|
{/* Right section: Pricing */}
|
||||||
{showPricing && (
|
{showPricing && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4 flex-shrink-0">
|
||||||
{totals.monthlyTotal > 0 && (
|
{totals.monthlyTotal > 0 && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
|
||||||
@ -163,41 +173,6 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<p className="text-sm text-gray-700">{statusDescriptor.description}</p>
|
|
||||||
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
|
|
||||||
</div>
|
|
||||||
{displayItems.length > 1 && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{displayItems.slice(1, 4).map(item => (
|
|
||||||
<span
|
|
||||||
key={item.id}
|
|
||||||
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{displayItems.length > 4 && (
|
|
||||||
<span className="inline-flex items-center text-xs text-gray-400">
|
|
||||||
+{displayItems.length - 4} more
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{isInteractive && (
|
|
||||||
<div className="border-t border-slate-100 bg-slate-50/30 px-6 py-3">
|
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-blue-600">
|
|
||||||
View details
|
|
||||||
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,28 +2,41 @@
|
|||||||
|
|
||||||
export function OrderCardSkeleton() {
|
export function OrderCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm">
|
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<span className="pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full bg-slate-200" aria-hidden />
|
<div className="px-6 py-4">
|
||||||
<div className="animate-pulse space-y-6">
|
<div className="animate-pulse flex items-start justify-between gap-6">
|
||||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
{/* Left section: Icon + Service info + Status */}
|
||||||
<div className="flex flex-1 items-start gap-4">
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||||
<div className="h-12 w-12 rounded-xl bg-slate-100" />
|
<div className="h-10 w-10 rounded-lg bg-slate-100 flex-shrink-0" />
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 min-w-0 space-y-3">
|
||||||
<div className="h-6 w-3/4 rounded bg-slate-200" />
|
{/* Service name + Status pill inline */}
|
||||||
<div className="h-3 w-5/6 rounded bg-slate-200" />
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="h-5 w-32 rounded bg-slate-200" />
|
||||||
|
<div className="h-5 w-20 rounded-full bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
{/* Order number and date */}
|
||||||
|
<div className="h-3 w-40 rounded bg-slate-100" />
|
||||||
|
{/* Display items */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="h-4 w-24 rounded-full bg-slate-200" />
|
<div className="h-6 w-24 rounded-md bg-slate-100" />
|
||||||
<div className="h-4 w-20 rounded-full bg-slate-200" />
|
<div className="h-6 w-20 rounded-md bg-slate-100" />
|
||||||
<div className="h-4 w-16 rounded-full bg-slate-100" />
|
<div className="h-6 w-16 rounded-md bg-slate-50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-6 w-24 rounded-full bg-slate-200" />
|
|
||||||
|
{/* Right section: Pricing */}
|
||||||
|
<div className="flex items-start gap-4 flex-shrink-0">
|
||||||
|
<div className="text-right space-y-1">
|
||||||
|
<div className="h-2 w-16 rounded bg-slate-100" />
|
||||||
|
<div className="h-6 w-20 rounded bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-32 rounded bg-slate-200" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default OrderCardSkeleton;
|
export default OrderCardSkeleton;
|
||||||
|
|
||||||
|
|||||||
@ -199,8 +199,8 @@ export function buildOrderDisplayItems(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't group items - show each one separately
|
// Map items to display format - keep the order from the backend
|
||||||
const displayItems = items.map((item, index) => {
|
return items.map((item, index) => {
|
||||||
const charges = aggregateCharges({ indices: [index], items: [item] });
|
const charges = aggregateCharges({ indices: [index], items: [item] });
|
||||||
const isBundled = Boolean(item.isBundledAddon);
|
const isBundled = Boolean(item.isBundledAddon);
|
||||||
|
|
||||||
@ -217,20 +217,6 @@ export function buildOrderDisplayItems(
|
|||||||
isBundle: isBundled,
|
isBundle: isBundled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: monthly subscriptions first, then one-time items
|
|
||||||
return displayItems.sort((a, b) => {
|
|
||||||
// Get the primary charge kind for each item
|
|
||||||
const aChargeKind = a.charges[0]?.kind ?? "other";
|
|
||||||
const bChargeKind = b.charges[0]?.kind ?? "other";
|
|
||||||
|
|
||||||
// Sort by charge kind (monthly first, then one-time)
|
|
||||||
const orderDiff = CHARGE_ORDER[aChargeKind] - CHARGE_ORDER[bChargeKind];
|
|
||||||
if (orderDiff !== 0) return orderDiff;
|
|
||||||
|
|
||||||
// If same charge kind, maintain original order
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeOrderDisplayItems(
|
export function summarizeOrderDisplayItems(
|
||||||
|
|||||||
@ -105,12 +105,12 @@ const ITEM_VISUAL_STYLES: Record<
|
|||||||
icon: "bg-green-50 text-green-600",
|
icon: "bg-green-50 text-green-600",
|
||||||
},
|
},
|
||||||
addon: {
|
addon: {
|
||||||
container: "border-slate-200 bg-white",
|
container: "border-purple-200 bg-white",
|
||||||
icon: "bg-slate-50 text-slate-600",
|
icon: "bg-purple-50 text-purple-600",
|
||||||
},
|
},
|
||||||
activation: {
|
activation: {
|
||||||
container: "border-slate-200 bg-white",
|
container: "border-green-200 bg-white",
|
||||||
icon: "bg-slate-50 text-slate-600",
|
icon: "bg-green-50 text-green-600",
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
container: "border-slate-200 bg-white",
|
container: "border-slate-200 bg-white",
|
||||||
@ -118,15 +118,7 @@ const ITEM_VISUAL_STYLES: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUNDLE_VISUAL_STYLE = {
|
|
||||||
container: "border-purple-200 bg-white",
|
|
||||||
icon: "bg-purple-50 text-purple-600",
|
|
||||||
};
|
|
||||||
|
|
||||||
const getItemVisualStyle = (item: OrderDisplayItem) => {
|
const getItemVisualStyle = (item: OrderDisplayItem) => {
|
||||||
if (item.isBundle) {
|
|
||||||
return BUNDLE_VISUAL_STYLE;
|
|
||||||
}
|
|
||||||
return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other;
|
return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -379,25 +371,15 @@ export function OrderDetailContainer() {
|
|||||||
displayItems.map((item, itemIndex) => {
|
displayItems.map((item, itemIndex) => {
|
||||||
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other;
|
||||||
const Icon = categoryConfig.icon;
|
const Icon = categoryConfig.icon;
|
||||||
const prevItem = itemIndex > 0 ? displayItems[itemIndex - 1] : null;
|
|
||||||
const showBundleStart = item.isBundle && (!prevItem || !prevItem.isBundle);
|
|
||||||
const style = getItemVisualStyle(item);
|
const style = getItemVisualStyle(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
{showBundleStart && (
|
|
||||||
<div className="mb-2 mt-3 flex items-center gap-2 px-1">
|
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wider text-purple-600">
|
|
||||||
Bundled
|
|
||||||
</span>
|
|
||||||
<div className="h-px flex-1 bg-purple-200"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-3 rounded-xl border px-4 py-4 sm:flex-row sm:items-center sm:justify-between",
|
"flex flex-col gap-3 rounded-xl border px-4 py-4 sm:flex-row sm:items-center sm:justify-between",
|
||||||
style.container,
|
style.container,
|
||||||
!showBundleStart && itemIndex > 0 && "border-t-0 rounded-t-none"
|
itemIndex > 0 && "border-t-0 rounded-t-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Icon + Title & Category | Price */}
|
{/* Icon + Title & Category | Price */}
|
||||||
|
|||||||
@ -36,7 +36,7 @@ function OrdersSuccessBanner() {
|
|||||||
|
|
||||||
export function OrdersListContainer() {
|
export function OrdersListContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: orders = [], isLoading, isError, error, refetch, isFetching } = useOrdersList();
|
const { data: orders, isLoading, isError, error, refetch, isFetching } = useOrdersList();
|
||||||
|
|
||||||
const { errorMessage, showRetry } = useMemo(() => {
|
const { errorMessage, showRetry } = useMemo(() => {
|
||||||
if (!isError) {
|
if (!isError) {
|
||||||
@ -86,7 +86,7 @@ export function OrdersListContainer() {
|
|||||||
</AlertBanner>
|
</AlertBanner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading || !orders ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: 4 }).map((_, idx) => (
|
{Array.from({ length: 4 }).map((_, idx) => (
|
||||||
<OrderCardSkeleton key={idx} />
|
<OrderCardSkeleton key={idx} />
|
||||||
|
|||||||
@ -25,6 +25,18 @@ import type {
|
|||||||
SalesforceOrderRecord,
|
SalesforceOrderRecord,
|
||||||
} from "./raw.types";
|
} from "./raw.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get sort priority for item class
|
||||||
|
*/
|
||||||
|
function getItemClassSortPriority(itemClass?: string): number {
|
||||||
|
if (!itemClass) return 4;
|
||||||
|
const normalized = itemClass.toLowerCase();
|
||||||
|
if (normalized === 'service') return 1;
|
||||||
|
if (normalized === 'installation' || normalized === 'activation') return 2;
|
||||||
|
if (normalized === 'add-on') return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a Salesforce OrderItem record into domain details + summary.
|
* Transform a Salesforce OrderItem record into domain details + summary.
|
||||||
*/
|
*/
|
||||||
@ -103,6 +115,13 @@ export function transformSalesforceOrderDetails(
|
|||||||
transformSalesforceOrderItem(record, fieldMap)
|
transformSalesforceOrderItem(record, fieldMap)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sort items by item class priority (Service -> Installation/Activation -> Add-on -> Others)
|
||||||
|
transformedItems.sort((a, b) => {
|
||||||
|
const priorityA = getItemClassSortPriority(a.summary.itemClass);
|
||||||
|
const priorityB = getItemClassSortPriority(b.summary.itemClass);
|
||||||
|
return priorityA - priorityB;
|
||||||
|
});
|
||||||
|
|
||||||
const items = transformedItems.map(item => item.details);
|
const items = transformedItems.map(item => item.details);
|
||||||
const itemsSummary = transformedItems.map(item => item.summary);
|
const itemsSummary = transformedItems.map(item => item.summary);
|
||||||
|
|
||||||
@ -132,9 +151,18 @@ export function transformSalesforceOrderSummary(
|
|||||||
itemRecords: SalesforceOrderItemRecord[],
|
itemRecords: SalesforceOrderItemRecord[],
|
||||||
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap
|
||||||
): OrderSummary {
|
): OrderSummary {
|
||||||
const itemsSummary = itemRecords.map(record =>
|
const transformedItems = itemRecords.map(record =>
|
||||||
transformSalesforceOrderItem(record, fieldMap).summary
|
transformSalesforceOrderItem(record, fieldMap)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sort items by item class priority (Service -> Installation/Activation -> Add-on -> Others)
|
||||||
|
transformedItems.sort((a, b) => {
|
||||||
|
const priorityA = getItemClassSortPriority(a.summary.itemClass);
|
||||||
|
const priorityB = getItemClassSortPriority(b.summary.itemClass);
|
||||||
|
return priorityA - priorityB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemsSummary = transformedItems.map(item => item.summary);
|
||||||
return buildOrderSummary(order, itemsSummary, fieldMap);
|
return buildOrderSummary(order, itemsSummary, fieldMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -80,6 +80,37 @@ export function mapOrderToWhmcsItems(
|
|||||||
/**
|
/**
|
||||||
* Build WHMCS AddOrder API payload from parameters
|
* Build WHMCS AddOrder API payload from parameters
|
||||||
* Converts structured params into WHMCS API array format
|
* Converts structured params into WHMCS API array format
|
||||||
|
*
|
||||||
|
* WHMCS AddOrder API Documentation:
|
||||||
|
* @see https://developers.whmcs.com/api-reference/addorder/
|
||||||
|
*
|
||||||
|
* Required Parameters:
|
||||||
|
* - clientid (int): The client ID
|
||||||
|
* - paymentmethod (string): Payment method (e.g. "stripe", "paypal", "mailin")
|
||||||
|
* - pid (int[]): Array of product IDs
|
||||||
|
* - qty (int[]): Array of product quantities (REQUIRED! Without this, no products are added)
|
||||||
|
* - billingcycle (string[]): Array of billing cycles (e.g. "monthly", "onetime")
|
||||||
|
*
|
||||||
|
* Optional Parameters:
|
||||||
|
* - promocode (string): Promotion code to apply
|
||||||
|
* - noinvoice (bool): Don't create invoice
|
||||||
|
* - noinvoiceemail (bool): Don't send invoice email
|
||||||
|
* - noemail (bool): Don't send order confirmation email
|
||||||
|
* - configoptions (string[]): Base64 encoded serialized arrays of config options
|
||||||
|
* - customfields (string[]): Base64 encoded serialized arrays of custom fields
|
||||||
|
*
|
||||||
|
* Response Fields:
|
||||||
|
* - result: "success" or "error"
|
||||||
|
* - orderid: The created order ID
|
||||||
|
* - serviceids: Comma-separated service IDs created
|
||||||
|
* - addonids: Comma-separated addon IDs created
|
||||||
|
* - domainids: Comma-separated domain IDs created
|
||||||
|
* - invoiceid: The invoice ID created
|
||||||
|
*
|
||||||
|
* Common Errors:
|
||||||
|
* - "No items added to cart so order cannot proceed" - Missing or invalid qty parameter
|
||||||
|
* - "Client ID Not Found" - Invalid client ID
|
||||||
|
* - "Invalid Payment Method" - Payment method not recognized
|
||||||
*/
|
*/
|
||||||
export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload {
|
export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload {
|
||||||
const pids: string[] = [];
|
const pids: string[] = [];
|
||||||
|
|||||||
@ -1,8 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Orders Domain - WHMCS Provider Raw Types
|
* Orders Domain - WHMCS Provider Raw Types
|
||||||
*
|
*
|
||||||
* Raw types for WHMCS AddOrder API request/response.
|
* Raw types for WHMCS API request/response.
|
||||||
* Based on WHMCS API documentation: https://developers.whmcs.com/api-reference/addorder/
|
*
|
||||||
|
* WHMCS AddOrder API:
|
||||||
|
* @see https://developers.whmcs.com/api-reference/addorder/
|
||||||
|
* Adds an order to a client. Returns orderid, serviceids, addonids, domainids, invoiceid.
|
||||||
|
* CRITICAL: Must include qty[] parameter or no products will be added to the order!
|
||||||
|
*
|
||||||
|
* WHMCS AcceptOrder API:
|
||||||
|
* @see https://developers.whmcs.com/api-reference/acceptorder/
|
||||||
|
* Accepts a pending order. Parameters:
|
||||||
|
* - orderid (int): Required - The order ID to accept
|
||||||
|
* - autosetup (bool): Optional - Send request to product module to activate service
|
||||||
|
* - sendemail (bool): Optional - Send automatic emails (Product Welcome, etc.)
|
||||||
|
* - registrar (string): Optional - Specific registrar for domains
|
||||||
|
* - sendregistrar (bool): Optional - Send request to registrar
|
||||||
|
* Returns: { result: "success" } on success
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user