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:
barsa 2025-11-05 18:17:59 +09:00
parent e1f3160145
commit b3e3689315
16 changed files with 336 additions and 210 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[] = [];

View File

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