/** * Default services form: displays shipping zone services, quote search, and selected couriers. */ import { __, sprintf } from '@wordpress/i18n'; import { memo, useEffect, useMemo, useState, useCallback, } from '@wordpress/element'; import { Card, CardBody, Flex, Notice } from '@wordpress/components'; import { Spinner } from '@woocommerce/components'; import type { DefaultServiceMethod, DefaultServiceZone, Service, } from '../../types'; import type { DefaultShippingSettings, PluginSettings, UpdateSettingsPayload, UpdateSettingsResponse, } from '../../types/settings'; import type { PackagePreset } from '../../types/settings'; import { useQuotesFetcher } from '../../shared/hooks'; import { CourierSelectWithList } from '../../shared/components'; import { convertToAppSettings } from '../../shared/utils/parse'; type MethodRow = { zoneId: number; zoneName: string; countries: string[]; countryCodes: string[]; countryCount: number; method: DefaultServiceMethod; }; function getQuoteCacheKey(methodId: string, row: MethodRow): string { const country = row.countryCodes?.[0]?.trim() ?? ''; return `${methodId}:${country}`; } function pillStyle(background: string, color: string) { return { alignItems: 'center' as const, gap: 6, padding: '2px 8px', borderRadius: 12, background, color, fontSize: 12, lineHeight: 1.4, }; } export interface DefaultServicesFormProps { zones: DefaultServiceZone[]; loading?: boolean; error?: string | null; storeAddress?: DefaultShippingSettings | null; defaultPackage?: PackagePreset | null; /** From settings.overrides.defaultWeightKg (DefaultSettingsOverrides). No value = user must confirm before fetching when package has no weight. */ defaultWeightKg?: number; weightUnit?: string; dimensionUnit?: string; settings: PluginSettings; handleSubmit: ( payload: UpdateSettingsPayload ) => Promise; } function DefaultServicesFormInner({ zones, loading = false, error = null, storeAddress = null, defaultPackage = null, defaultWeightKg, weightUnit = 'kg', dimensionUnit = 'cm', settings, handleSubmit, }: DefaultServicesFormProps) { const rows = useMemo( () => zones.flatMap((zone) => zone.methods.map((method) => ({ zoneId: zone.id, zoneName: zone.name, countries: zone.countries, countryCodes: zone.countryCodes ?? [], countryCount: zone.countryCount, method, })) ), [zones] ); const [selectedCouriers, setSelectedCouriers] = useState< Record >({}); const [savingMethodIds, setSavingMethodIds] = useState< Record >({}); const [saveFeedback, setSaveFeedback] = useState< Record< string, { status: 'success' | 'error'; message: string } | undefined > >({}); useEffect(() => { const next: Record = {}; for (const row of rows) { if ( ! row.method.id ) continue; const saved = settings?.defaultServiceCouriers?.[ row.method.id ]; next[ row.method.id ] = saved ?? row.method.services ?? []; } setSelectedCouriers(next); }, [rows, settings?.defaultServiceCouriers]); const effectiveWeight = defaultPackage && defaultPackage.weight > 0 ? defaultPackage.weight : typeof defaultWeightKg === 'number' && defaultWeightKg > 0 ? defaultWeightKg : 0; const hasWeightSet = effectiveWeight > 0; const origin = useMemo( () => storeAddress ? { country: storeAddress.country ?? '', countryName: storeAddress.country ?? '', postcode: storeAddress.postcode || undefined, } : { country: '', countryName: '', postcode: undefined as string | undefined, }, [storeAddress] ); const parcels = useMemo( () => defaultPackage ? [ { ref: 'default-package', weight: effectiveWeight, length: defaultPackage.length, width: defaultPackage.width, height: defaultPackage.height, value: 100, }, ] : [], [defaultPackage, effectiveWeight] ); const quotesFetcher = useQuotesFetcher({ origin, parcels }); const canFetchQuotesForRow = useCallback( (row: MethodRow): boolean => { if (!storeAddress?.country?.trim()) return false; if (!defaultPackage) return false; if ( defaultPackage.length <= 0 || defaultPackage.width <= 0 || defaultPackage.height <= 0 ) return false; if (!hasWeightSet) return false; const firstCode = row.countryCodes?.[0]?.trim(); if (!firstCode) return false; return true; }, [storeAddress, defaultPackage, hasWeightSet] ); const getQuoteMissingMessage = useCallback( (row: MethodRow): string | null => { if (!storeAddress?.country?.trim()) { return __( 'Set your store address in Default shipping details to fetch quotes.', 'parcel2go-shipping' ); } if (!defaultPackage) { return __( 'Choose or create a default package in Default package settings to fetch quotes.', 'parcel2go-shipping' ); } if ( defaultPackage.length <= 0 || defaultPackage.width <= 0 || defaultPackage.height <= 0 ) { return sprintf( __( 'Default package must have length, width and height set in %s (Default package settings).', 'parcel2go-shipping' ), dimensionUnit ); } if (!hasWeightSet) { return sprintf( __( 'Set a default weight in %s in Default settings overrides or add weight to your default package to fetch quotes.', 'parcel2go-shipping' ), weightUnit ); } if (!row.countryCodes?.[0]?.trim()) { return __( 'This zone has no country. Add at least one country to the shipping zone in WooCommerce to fetch quotes.', 'parcel2go-shipping' ); } return null; }, [storeAddress, defaultPackage, hasWeightSet, weightUnit, dimensionUnit] ); const applyServicesUpdate = useCallback( async ( methodId: string, next: Service[], successMessage: string, errorMessage: string ) => { const previous = selectedCouriers[methodId] ?? []; setSavingMethodIds((prev) => ({ ...prev, [methodId]: true })); setSaveFeedback((prev) => ({ ...prev, [methodId]: undefined })); setSelectedCouriers((prev) => ({ ...prev, [methodId]: next })); try { const appSettings = convertToAppSettings(settings); const payload: UpdateSettingsPayload = { type: 'App', data: { ...appSettings, defaultServices: { ...appSettings.defaultServices, [methodId]: next, }, }, }; const result = await handleSubmit(payload); if (!result.success) { setSaveFeedback((prev) => ({ ...prev, [methodId]: { status: 'error', message: result.message || errorMessage, }, })); setSelectedCouriers((prev) => ({ ...prev, [methodId]: previous, })); } setSaveFeedback((prev) => ({ ...prev, [methodId]: { status: 'success', message: result.message || successMessage, }, })); } catch { setSelectedCouriers((prev) => ({ ...prev, [methodId]: previous, })); setSaveFeedback((prev) => ({ ...prev, [methodId]: { status: 'error', message: errorMessage }, })); } finally { setSavingMethodIds((prev) => ({ ...prev, [methodId]: false })); } }, [selectedCouriers, handleSubmit, settings] ); const addCourierFromQuote = useCallback( async (methodId: string, service: Service) => { const current = selectedCouriers[methodId] ?? []; if (current.some((c) => c.slug === service.slug)) return; const next: Service[] = [...current, service]; await applyServicesUpdate( methodId, next, __('Default service added successfully.', 'parcel2go-shipping'), __( 'Failed to add default service. Please try again.', 'parcel2go-shipping' ) ); quotesFetcher.setActiveCacheKey(null); }, [selectedCouriers, applyServicesUpdate, quotesFetcher] ); const removeCourier = useCallback( async (methodId: string, service: Service) => { const current = selectedCouriers[methodId] ?? []; const next = current.filter((c) => c.slug !== service.slug); await applyServicesUpdate( methodId, next, __( 'Default service removed successfully.', 'parcel2go-shipping' ), __( 'Failed to remove default service. Please try again.', 'parcel2go-shipping' ) ); }, [selectedCouriers, applyServicesUpdate] ); const clearAll = useCallback( async (methodId: string) => { await applyServicesUpdate( methodId, [], __( 'Default services cleared successfully.', 'parcel2go-shipping' ), __( 'Failed to clear default services. Please try again.', 'parcel2go-shipping' ) ); quotesFetcher.setActiveCacheKey(null); }, [applyServicesUpdate, quotesFetcher] ); if (loading) { return ( ); } if (error) { return ( {error} ); } if (!rows.length) { return ( {__('No shipping zones/services found.', 'parcel2go-shipping')} ); } return ( <>
{__('Default Services', 'parcel2go-shipping')}
{rows.map((row) => { const methodId = row.method.id; const chips = selectedCouriers[methodId] ?? []; const isSaving = savingMethodIds[methodId] === true; const feedback = saveFeedback[methodId] ?? null; const canFetch = canFetchQuotesForRow(row); const missingMessage = getQuoteMissingMessage(row); return (
{row.method.title} {row.method.price}
{isSaving && ( {__( 'Saving default services…', 'parcel2go-shipping' )} )} {feedback && ( setSaveFeedback((prev) => ({ ...prev, [methodId]: undefined, })) } > {feedback.message} )}
{__( 'Zone', 'parcel2go-shipping' )} : {' '} {row.zoneName}
{__( 'Countries', 'parcel2go-shipping' )}
{row.countryCount}{' '} {row.countryCount === 1 ? __( 'country', 'parcel2go-shipping' ) : __( 'countries', 'parcel2go-shipping' )}
{row.method.conditions.length > 0 && (
{__( 'Conditions', 'parcel2go-shipping' )}
{row.method.conditions.map( (condition) => ( {condition} ) )}
)}
{__( 'Couriers', 'parcel2go-shipping' )} { const key = getQuoteCacheKey( methodId, row ); const dest = { country: row.countryCodes?.[0]?.trim() ?? '', }; quotesFetcher.setActiveCacheKey( key ); quotesFetcher.fetchQuotes( key, dest ); }} quotesList={ quotesFetcher.quotesList } quotesLoading={ quotesFetcher.quotesLoading } quotesError={ quotesFetcher.quotesError } selectedCouriers={chips} onAddCourier={(service) => addCourierFromQuote( methodId, service ) } onRemoveCourier={(service) => removeCourier( methodId, service ) } disabled={isSaving} canFetch={canFetch} missingMessage={missingMessage} clearAllButton={{ label: __( 'Clear All', 'parcel2go-shipping' ), onClick: () => clearAll(methodId), disabled: isSaving, isBusy: isSaving, }} />
); })}
); } export const DefaultServicesForm = memo(DefaultServicesFormInner);