import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { getBatchRatesPath } from 'data/routes';
import type { LocationResponse } from 'types';
import type { OrderRate, OrderRatesMap, OrderRateErrors } from './types';
type OriginInput = Partial< LocationResponse >;
/**
* One order's contribution to the batch rate request. The modal resolves
* the effective (auto-assigned or manually-picked) package per order and
* hands it here already normalized to numeric dimensions + weight.
*/
export interface RateRequestOrder {
order_id: number;
destination: Record< string, unknown >;
package: {
length: number;
width: number;
height: number;
weight: number;
box_id: string;
is_letter: boolean;
};
}
interface UseOrderRatesResult {
isResolving: boolean;
/** Order ids with a rate request currently in flight. */
resolvingIds: number[];
error: Error | null;
rates: OrderRatesMap;
rateErrors: OrderRateErrors;
}
// Every order's single package is sent under this id, so the response is
// keyed by it. Mirrors the single-order flow's `default_box` convention.
const PACKAGE_ID = 'default_box';
const EMPTY_RATES: OrderRatesMap = {};
const EMPTY_ERRORS: OrderRateErrors = {};
/**
* The single-order rate endpoint runs each address through the server's
* `Address` sanitizer (which renames country→country_code,
* state→state_code, address_1→address). The batch endpoint skips that
* step and feeds the raw payload straight into `normalize_api_rate_request`,
* which then reads `country_code`/`state_code`. So the batch caller must
* pre-shape addresses or the server blanks the country and the Connect
* server rejects the request ("origin.country must be one of […]").
*/
export const toRateAddress = (
addr: Record< string, unknown > | null | undefined
): Record< string, unknown > => {
const source = ( addr ?? {} ) as Record< string, unknown >;
const { country, state, ...rest } = source;
delete rest.id;
delete rest.is_verified;
return {
...rest,
country_code: country ?? '',
state_code: state ?? '',
};
};
interface RawRate {
rate_id?: string;
service_id?: string;
carrier_id?: string;
shipment_id?: string;
title?: string;
rate?: number;
retail_rate?: number;
delivery_days?: number;
}
interface RawRateType {
rates?: RawRate[];
}
interface RawErrorShape {
code?: string;
message?: string;
}
type RawPackageBucket = Record< string, RawRateType >;
// The endpoint returns either `{ error: {...} }` or a package-keyed map of
// rate buckets per order. Keep it loose and narrow at runtime.
type RawOrderEntry = unknown;
const getErrorShape = ( entry: RawOrderEntry ): RawErrorShape | null => {
if ( ! entry || typeof entry !== 'object' ) {
return null;
}
const maybe = ( entry as { error?: unknown } ).error;
return maybe && typeof maybe === 'object'
? ( maybe as RawErrorShape )
: null;
};
const normalizeRates = ( rawRates: RawRate[] ): OrderRate[] =>
rawRates
.filter( ( r ): r is RawRate & { rate_id: string } =>
Boolean( r.rate_id )
)
.map( ( r ): OrderRate => {
const title = r.title?.trim();
const rate = typeof r.rate === 'number' ? r.rate : 0;
return {
rateId: r.rate_id,
serviceId: r.service_id ?? '',
carrierId: ( r.carrier_id ?? '' ) as OrderRate[ 'carrierId' ],
shipmentId: r.shipment_id ?? '',
title:
title && title.length > 0
? title
: r.service_id ?? r.rate_id,
rate,
retailRate:
typeof r.retail_rate === 'number' ? r.retail_rate : rate,
deliveryDays:
typeof r.delivery_days === 'number'
? r.delivery_days
: undefined,
};
} );
const parseBatchResponse = (
response: Record< string, RawOrderEntry > | undefined
): { rates: OrderRatesMap; errors: OrderRateErrors } => {
const rates: OrderRatesMap = {};
const errors: OrderRateErrors = {};
if ( ! response ) {
return { rates, errors };
}
for ( const [ key, entry ] of Object.entries( response ) ) {
const orderId = Number( key );
if ( ! Number.isFinite( orderId ) ) {
continue;
}
const errorShape = getErrorShape( entry );
if ( errorShape ) {
const message = errorShape.message?.trim();
errors[ orderId ] =
message && message.length > 0
? message
: 'Could not get rates for this order.';
continue;
}
if ( ! entry || typeof entry !== 'object' ) {
continue;
}
// Response is keyed by the package id we sent; fall back to the
// first package bucket if the server echoed a different id.
const buckets = entry as Record< string, RawPackageBucket >;
const packageBucket =
buckets[ PACKAGE_ID ] ?? Object.values( buckets )[ 0 ];
const rawRates = packageBucket?.default?.rates ?? [];
rates[ orderId ] = normalizeRates( rawRates );
}
return { rates, errors };
};
/**
* Quote rates for every order in one POST to the batch rate endpoint,
* using the shared origin and each order's resolved package. Refetches
* only when the origin or a package/destination signature changes — not
* on unrelated re-renders.
*/
export const useOrderRates = (
origin: OriginInput | null,
orders: RateRequestOrder[]
): UseOrderRatesResult => {
const [ rates, setRates ] = useState< OrderRatesMap >( EMPTY_RATES );
const [ rateErrors, setRateErrors ] =
useState< OrderRateErrors >( EMPTY_ERRORS );
const [ isResolving, setIsResolving ] = useState< boolean >( false );
// Order ids whose rate request is currently in flight, so each row can
// show a progress bar instead of a stale selected rate.
const [ resolvingIds, setResolvingIds ] = useState< number[] >( [] );
const [ error, setError ] = useState< Error | null >( null );
// Quote-able orders need a real box (positive dims) and a destination.
const quotable = useMemo(
() =>
orders.filter(
( o ) =>
o.package.length > 0 &&
o.package.width > 0 &&
o.package.height > 0
),
[ orders ]
);
// Per-order fingerprint: changes only when *this* order's box or
// destination changes, so a single manual package edit re-quotes that
// one order instead of the whole batch.
const orderSig = ( o: RateRequestOrder ): string =>
JSON.stringify( [
o.package.length,
o.package.width,
o.package.height,
o.package.weight,
o.package.box_id,
o.package.is_letter,
o.destination.postcode,
o.destination.country,
o.destination.state,
] );
// Origin affects every order's rate, so a changed origin must re-quote
// all of them.
const originSig = origin
? JSON.stringify( [
origin.postcode,
origin.country,
origin.state,
origin.address_1,
] )
: '';
// Effect dependency — flips whenever the origin or *any* order's
// fingerprint changes; the effect then diffs to fetch only what moved.
const signature = useMemo( () => {
if ( ! origin || quotable.length === 0 ) {
return '';
}
return (
originSig +
'|' +
quotable
.map( ( o ) => `${ o.order_id }:${ orderSig( o ) }` )
.join( ';' )
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- originSig/orderSig are pure derivations of the deps
}, [ origin, quotable, originSig ] );
// Latest payload + the per-order signatures last fetched, reachable
// from the effect without churning its dependency on array identity.
const payloadRef = useRef< {
origin: OriginInput | null;
quotable: RateRequestOrder[];
} >( { origin, quotable } );
payloadRef.current = { origin, quotable };
const fetchedSigRef = useRef< Map< number, string > >( new Map() );
const originSigRef = useRef< string | null >( null );
useEffect( () => {
const { origin: reqOrigin, quotable: reqOrders } = payloadRef.current;
if ( ! reqOrigin || reqOrders.length === 0 ) {
fetchedSigRef.current = new Map();
originSigRef.current = null;
setRates( EMPTY_RATES );
setRateErrors( EMPTY_ERRORS );
setIsResolving( false );
setResolvingIds( [] );
setError( null );
return;
}
const currentIds = new Set( reqOrders.map( ( o ) => o.order_id ) );
// Drop rows that are no longer quote-able (package cleared, order
// removed) so stale rates can't linger.
fetchedSigRef.current.forEach( ( _sig, id ) => {
if ( ! currentIds.has( id ) ) {
fetchedSigRef.current.delete( id );
}
} );
const prune = < T >(
map: Record< number, T >
): Record< number, T > => {
let changed = false;
const next: Record< number, T > = {};
Object.entries( map ).forEach( ( [ key, value ] ) => {
if ( currentIds.has( Number( key ) ) ) {
next[ Number( key ) ] = value;
} else {
changed = true;
}
} );
return changed ? next : map;
};
setRates( ( prev ) => prune( prev ) );
setRateErrors( ( prev ) => prune( prev ) );
// A changed origin invalidates every quote; otherwise only the
// orders whose own fingerprint moved (or that were never fetched).
const originChanged =
originSigRef.current !== null && originSigRef.current !== originSig;
const toFetch = reqOrders.filter( ( o ) => {
if ( originChanged ) {
return true;
}
return fetchedSigRef.current.get( o.order_id ) !== orderSig( o );
} );
if ( toFetch.length === 0 ) {
originSigRef.current = originSig;
setIsResolving( false );
setResolvingIds( [] );
return;
}
let cancelled = false;
setIsResolving( true );
// Mark these orders in-flight so their rows show a progress bar
// instead of a stale selected rate.
setResolvingIds( toFetch.map( ( o ) => o.order_id ) );
setError( null );
const originAddress = toRateAddress(
reqOrigin as unknown as Record< string, unknown >
);
// The rate-address contract requires a phone, but shipping-context
// drops an empty shipping phone and we don't have the order's
// billing phone client-side. For a *quote* (no label bought yet)
// the store phone is an acceptable stand-in so carriers like USPS
// don't reject the whole order.
const fallbackPhone =
typeof originAddress.phone === 'string' ? originAddress.phone : '';
apiFetch< Record< string, RawOrderEntry > >( {
path: getBatchRatesPath(),
method: 'POST',
data: {
origin: originAddress,
orders: toFetch.map( ( o ) => {
const destination = toRateAddress( o.destination );
const destPhone =
typeof destination.phone === 'string' &&
destination.phone.trim().length > 0
? destination.phone
: fallbackPhone;
return {
order_id: o.order_id,
destination: { ...destination, phone: destPhone },
packages: [
{
id: PACKAGE_ID,
box_id: o.package.box_id,
length: o.package.length,
width: o.package.width,
height: o.package.height,
weight: o.package.weight,
is_letter: o.package.is_letter,
},
],
};
} ),
},
} )
.then( ( response ) => {
if ( cancelled ) {
return;
}
const parsed = parseBatchResponse( response );
// Merge only the fetched orders; leave every other order's
// existing rates untouched.
setRates( ( prev ) => {
const next = { ...prev };
toFetch.forEach( ( o ) => {
if ( o.order_id in parsed.rates ) {
next[ o.order_id ] = parsed.rates[ o.order_id ];
} else {
delete next[ o.order_id ];
}
} );
return next;
} );
setRateErrors( ( prev ) => {
const next = { ...prev };
toFetch.forEach( ( o ) => {
if ( o.order_id in parsed.errors ) {
next[ o.order_id ] = parsed.errors[ o.order_id ];
} else {
delete next[ o.order_id ];
}
} );
return next;
} );
toFetch.forEach( ( o ) =>
fetchedSigRef.current.set( o.order_id, orderSig( o ) )
);
originSigRef.current = originSig;
setIsResolving( false );
setResolvingIds( [] );
} )
.catch( ( err: unknown ) => {
if ( cancelled ) {
return;
}
const failure =
err instanceof Error
? err
: new Error( 'Failed to fetch rates.' );
// Invalidate exactly the orders we just re-quoted: their
// inputs changed, so any prior rate is stale and must not
// keep reading as "Ready". Orders that weren't part of
// this request keep their valid rates (no full-table wipe).
setRates( ( prev ) => {
const next = { ...prev };
toFetch.forEach( ( o ) => delete next[ o.order_id ] );
return next;
} );
setRateErrors( ( prev ) => {
const next = { ...prev };
toFetch.forEach( ( o ) => {
next[ o.order_id ] = failure.message;
} );
return next;
} );
// Drop their fingerprints so an unchanged signature still
// retries instead of treating them as already fetched.
toFetch.forEach( ( o ) =>
fetchedSigRef.current.delete( o.order_id )
);
setError( failure );
setIsResolving( false );
setResolvingIds( [] );
} );
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- `signature` encodes every input the effect reads
}, [ signature ] );
return useMemo(
() => ( { isResolving, resolvingIds, error, rates, rateErrors } ),
[ isResolving, resolvingIds, error, rates, rateErrors ]
);
};