/** * Turns the GET /wcshipping/v1/packages payload into the flat list of * box-type `AssignablePackage`s the per-order package dropdown renders. * Kept separate from the store/hook so the bulk-labels @wordpress/data * store can import it without a circular dependency. */ import type { AssignablePackage } from './types'; /** * Raw shapes from GET /wcshipping/v1/packages. Only the fields we need * are modeled; the endpoint also returns storeOptions / success etc. */ interface RawCustomPackage { id?: string; name?: string; dimensions?: string; length?: number | string; width?: number | string; height?: number | string; box_weight?: number; boxWeight?: number; is_letter?: boolean; isLetter?: boolean; type?: string; } interface RawPredefinedDefinition { id?: string; name?: string; dimensions?: string; inner_dimensions?: string; outer_dimensions?: string; box_weight?: number; is_letter?: boolean; isLetter?: boolean; type?: string; } type RawSchema = Record< string, Record< string, { definitions?: RawPredefinedDefinition[] } > >; /** * The endpoint shape has drifted between versions (and the docs vs. the * PHP differ on whether custom packages carry `type`). Model every * nesting we've seen so a small shape change can't silently empty the * dropdown again. */ export interface RawPackagesResponse { packages?: { saved?: { custom?: RawCustomPackage[]; predefined?: Record< string, string[] >; }; custom?: RawCustomPackage[]; predefined?: RawSchema | Record< string, string[] >; }; custom?: RawCustomPackage[]; predefined?: Record< string, string[] >; } const toNumber = ( value: unknown ): number => { const n = typeof value === 'string' ? parseFloat( value ) : Number( value ); return Number.isFinite( n ) ? n : 0; }; interface ParsedDims { length: number; width: number; height: number; } /** * Resolve numeric L/W/H from explicit fields, falling back to parsing a * "L x W x H" string. Zeros mean "unknown" — callers decide whether that * disqualifies the box from a rate request. */ const parseDims = ( length?: number | string, width?: number | string, height?: number | string, dimensionsString?: string ): ParsedDims => { const explicit = [ length, width, height ].map( toNumber ); if ( explicit.every( ( n ) => n > 0 ) ) { return { length: explicit[ 0 ], width: explicit[ 1 ], height: explicit[ 2 ], }; } if ( dimensionsString ) { const matched = /([-.0-9]+).+?([-.0-9]+).+?([-.0-9]+)/.exec( dimensionsString ); if ( matched ) { const parsed = matched.slice( 1, 4 ).map( Number ); if ( parsed.every( ( n ) => Number.isFinite( n ) && n > 0 ) ) { return { length: parsed[ 0 ], width: parsed[ 1 ], height: parsed[ 2 ], }; } } } return { length: 0, width: 0, height: 0 }; }; /** Compact "L×W×H" for the package cell; empty when any dimension is unknown. */ const formatDims = ( dims: ParsedDims ): string => [ dims.length, dims.width, dims.height ].every( ( n ) => n > 0 ) ? [ dims.length, dims.width, dims.height ].join( '×' ) : ''; /** * Whether a package is a rigid box. The auto-assign service uses a strict * `type === 'box'` filter, but the GET /packages payload has been seen * without an explicit `type` (only `is_letter`), and `Package::from_array` * itself defaults a missing type to "box". So treat a package as a box * unless it's *explicitly* an envelope / letter — a stricter check is * what was wrongly hiding the merchant's custom boxes from the dropdown. */ const isBox = ( pkg: { is_letter?: boolean; isLetter?: boolean; type?: string; } ): boolean => { if ( pkg.type ) { return pkg.type.toLowerCase() === 'box'; } return pkg.is_letter !== true && pkg.isLetter !== true; }; /** Does this look like the predefined *schema* (carrier → groups → definitions)? */ const looksLikeSchema = ( value: unknown ): value is RawSchema => { if ( ! value || typeof value !== 'object' ) { return false; } const firstCarrier = Object.values( value as Record< string, unknown > )[ 0 ]; if ( ! firstCarrier || typeof firstCarrier !== 'object' ) { return false; } const firstGroup = Object.values( firstCarrier as Record< string, unknown > )[ 0 ]; return ( !! firstGroup && typeof firstGroup === 'object' && 'definitions' in ( firstGroup as object ) ); }; export const buildAssignablePackages = ( response: RawPackagesResponse | undefined ): AssignablePackage[] => { const pkgs = response?.packages; const predefinedAtPkgs = pkgs?.predefined; const predefinedIsSchema = looksLikeSchema( predefinedAtPkgs ); // Custom boxes can arrive at packages.saved.custom, packages.custom, or // the top level depending on endpoint/version. const customList: RawCustomPackage[] = pkgs?.saved?.custom ?? pkgs?.custom ?? response?.custom ?? []; // Starred predefined map (carrier → [ids]) vs. the full predefined // schema (carrier → groups → definitions). Either can sit at // packages.predefined depending on the shape, so disambiguate. const starredFromPkgs = ! predefinedIsSchema && predefinedAtPkgs ? ( predefinedAtPkgs as Record< string, string[] > ) : undefined; const starredPredefined: Record< string, string[] > = pkgs?.saved?.predefined ?? starredFromPkgs ?? response?.predefined ?? {}; const schema: RawSchema = predefinedIsSchema ? ( predefinedAtPkgs as RawSchema ) : {}; const result: AssignablePackage[] = []; // Saved custom boxes — self-describing, no schema lookup needed. for ( const custom of customList ) { const id = custom.id?.trim(); if ( ! id || ! isBox( custom ) ) { continue; } const customName = custom.name?.trim(); const dims = parseDims( custom.length, custom.width, custom.height, custom.dimensions ); result.push( { key: `custom:${ id }`, package_id: id, service_id: null, name: customName && customName.length > 0 ? customName : id, dimensions: formatDims( dims ), length: dims.length, width: dims.width, height: dims.height, weight: custom.box_weight ?? custom.boxWeight ?? 0, is_letter: false, } ); } // Starred predefined boxes — resolve each starred id against the // per-carrier schema so the option carries a real name + dimensions. for ( const [ carrierId, starredIds ] of Object.entries( starredPredefined ) ) { if ( ! Array.isArray( starredIds ) || starredIds.length === 0 ) { continue; } const starred = new Set( starredIds ); const carrierGroups = schema[ carrierId ] ?? {}; for ( const group of Object.values( carrierGroups ) ) { for ( const def of group.definitions ?? [] ) { const id = def.id?.trim(); if ( ! id || ! starred.has( id ) || ! isBox( def ) ) { continue; } const defName = def.name?.trim(); const dims = parseDims( undefined, undefined, undefined, def.inner_dimensions ?? def.dimensions ?? def.outer_dimensions ); result.push( { key: `predef:${ carrierId }:${ id }`, package_id: id, service_id: carrierId, name: defName && defName.length > 0 ? defName : id, dimensions: formatDims( dims ), length: dims.length, width: dims.width, height: dims.height, weight: def.box_weight ?? 0, is_letter: false, } ); } } } return result; };