/** * CheckoutErrorNotice — renders a P2G checkout / validation error as a human-readable * Notice with per-issue descriptions and contextual "fix" links. * * section → where the fix lives: * "origin" → store collection address → plugin Settings page * "destination" → customer shipping address → WooCommerce edit-order page * "parcel" → item/parcel details → WooCommerce edit-order page * "billingAddress" → billing address → WooCommerce edit-order page */ import { __ } from '@wordpress/i18n'; import { Notice, Icon } from '@wordpress/components'; import { cautionFilled } from '@wordpress/icons'; import type { CheckoutApiError, CheckoutIssue } from '../../types'; import { getEditOrderUrl, getPluginSettingsPageUrl } from '../utils'; export interface CheckoutErrorNoticeProps { error: CheckoutApiError; /** * URL for the plugin settings page — used for "origin" issues. * Defaults to getPluginSettingsPageUrl(). */ settingsUrl?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── /** Capitalise the first letter of a field name and split camelCase. */ function humaniseField(field: string): string { return field .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/^./, (c) => c.toUpperCase()); } /** Expand compact API field tokens into readable labels. */ function normaliseFieldToken(token: string): string { const value = token.toLowerCase(); const map: Record = { contactname: 'contact name', contentsummary: 'content summary', countryofmanufacture: 'country of manufacture', exportreason: 'export reason', discountcode: 'discount code', }; return map[value] ?? value.replace(/-/g, ' '); } type InferredContext = { section: string | null; field: string | null; }; /** * Infer section/field from documented error type codes. * Example: "missing-origin-postcode" -> { section: "origin", field: "Postcode" } */ function inferContextFromType(type: string): InferredContext { if (!type) return { section: null, field: null }; const code = type.split('/').pop() ?? ''; const parts = code.toLowerCase().split('-'); if (parts.length === 0) return { section: null, field: null }; const rule = parts[0]; const knownRules = [ 'invalid', 'missing', 'unavailable', 'ineligible', 'required', ]; if (!knownRules.includes(rule)) return { section: null, field: null }; const sectionToken = parts[1] ?? null; const fieldToken = parts.slice(2).join('-') || null; const sectionMap: Record = { origin: 'origin', destination: 'destination', billing: 'billingAddress', parcel: 'parcel', parcelcontent: 'parcel', shipment: 'parcel', customs: 'customs', customer: 'customer', service: 'service', quote: 'service', extra: 'service', collection: 'service', discountcode: 'service', }; const section = sectionToken ? sectionMap[sectionToken] ?? null : null; const field = fieldToken ? humaniseField(normaliseFieldToken(fieldToken)) : null; // Special cases where the token itself is the field. if (!field && sectionToken === 'service') { return { section, field: __('service', 'parcel2go-shipping') }; } return { section, field }; } /** * Infer the field name from the error type code. * E.g. "errorcodes/checkout/invalid-requirement" → null (generic) * "errorcodes/validate/invalid-parcelcontent-countryofmanufacture" → "country of manufacture" */ function inferFieldFromType(type: string): string | null { if (!type) return null; // Extract the last part after the final slash const parts = type.split('/'); const code = parts[parts.length - 1]; // Match "invalid--" pattern const match = code.match(/^invalid-[^-]+-(.+)$/); if (!match) return null; // "countryofmanufacture" → "country of manufacture" return humaniseField(match[1].replace(/([A-Z])/g, ' $1').toLowerCase()); } /** * Infer a field name from human-readable detail text. * E.g. "Item '0' had Height Is Required" → "Height" * "... postcode was missing a required value" → "Postcode" */ function inferFieldFromDetail(detail: string): string | null { if (!detail) return null; const hadRequiredMatch = detail.match( /\bhad\s+([A-Za-z][A-Za-z\s_-]{1,40}?)\s+Is Required\b/i ); if (hadRequiredMatch?.[1]) { return humaniseField(hadRequiredMatch[1].trim().replace(/[_-]+/g, ' ')); } const requiredMatch = detail.match(/\b([A-Za-z][A-Za-z\s_-]{1,40})\s+Is Required\b/i); if (requiredMatch?.[1]) { return humaniseField(requiredMatch[1].trim().replace(/[_-]+/g, ' ')); } const missingMatch = detail.match(/\bfield\s+([A-Za-z][A-Za-z0-9_-]{1,40})\b/i); if (missingMatch?.[1]) { return humaniseField(missingMatch[1].trim()); } return null; } /** * Parse an instance string to extract a human-readable location. * E.g. "request[0]:parcel[0]" → "Parcel 1" (1-indexed for users) * "request[0]" → "Request 1" */ function parseInstance(instance: string): string | null { if (!instance) return null; // Prefer the most specific segment (e.g. "parcel[0]" from "request[0]:parcel[0]"). const lastSegment = instance.split(':').pop() ?? instance; const match = lastSegment.match(/(\w+)\[(\d+)\]/); if (match) { const [, type, index] = match; const normalisedType = type.toLowerCase() === 'request' ? 'parcel' : type; const humanType = normalisedType.charAt(0).toUpperCase() + normalisedType.slice(1); return `${humanType} ${parseInt(index, 10) + 1}`; // 1-indexed for users } return null; } /** Map a section name to a short human-readable location label. */ function sectionLabel(section: string): string { switch (section) { case 'origin': case 'billingAddress': return __('store address in Settings', 'parcel2go-shipping'); case 'destination': return __('receiver delivery address on the order', 'parcel2go-shipping'); case 'parcel': return __('item details on the order', 'parcel2go-shipping'); case 'customs': return __('customs details on the order', 'parcel2go-shipping'); case 'customer': return __('customer details on the order', 'parcel2go-shipping'); case 'service': return __('service options on the order', 'parcel2go-shipping'); default: return __('related order', 'parcel2go-shipping'); } } interface ResolvedLink { href: string; label: string; } /** Check if a string or number can be parsed as a valid positive order ID. */ function isValidOrderId(ref: string | number | null | undefined): boolean { if (!ref) return false; const num = typeof ref === 'number' ? ref : parseInt(String(ref), 10); return !isNaN(num) && num > 0; } function resolveFixLink( issue: CheckoutIssue, settingsUrl: string, effectiveSection: string | null ): ResolvedLink | null { const section = effectiveSection ?? ''; // Explicit section → route based on section if (section === 'origin') { return { href: settingsUrl, label: __('Fix in Settings', 'parcel2go-shipping'), }; } if ( ['destination', 'parcel', 'billingAddress', 'customs', 'customer', 'service'].includes(section) && issue.itemRef && isValidOrderId(issue.itemRef) ) { const orderId = Number(issue.itemRef); return { href: getEditOrderUrl(orderId), label: __('Fix on order', 'parcel2go-shipping'), }; } // No explicit section, but itemRef looks like an order ID → treat as order error if (!section && issue.itemRef && isValidOrderId(issue.itemRef)) { const orderId = Number(issue.itemRef); return { href: getEditOrderUrl(orderId), label: __('Fix on order', 'parcel2go-shipping'), }; } return null; } // ── Component ───────────────────────────────────────────────────────────────── const listStyle: React.CSSProperties = { margin: '8px 0 0', padding: 0, listStyle: 'none', }; const itemStyle: React.CSSProperties = { display: 'flex', alignItems: 'flex-start', gap: 8, marginBottom: 6, }; const MAX_VISIBLE_GROUPS = 3; /** * Grouping key for deduplication: combines title + field + section * to identify identical or similar errors across multiple items/orders. */ function getGroupingKey( issue: CheckoutIssue, fieldLabel: string | null, effectiveSection: string | null ): string { return [ issue.title || 'error', fieldLabel || 'unknown', effectiveSection || 'none', ].join('::'); } interface GroupedIssue { key: string; issue: CheckoutIssue; // representative issue (first in group) issues: CheckoutIssue[]; // all issues in this group fieldLabel: string | null; locationLabel: string | null; effectiveSection: string | null; fixLink: ResolvedLink | null; affectedOrderIds: (string | number)[]; // unique order IDs } /** * Group issues by their key (title + field + section) to deduplicate * and collect all affected order IDs. */ function groupIssuesByKey(issues: CheckoutIssue[]): GroupedIssue[] { const groupMap = new Map(); issues.forEach((issue) => { const inferred = inferContextFromType(issue.type); const effectiveSection = issue.section ?? inferred.section; const fieldLabel = issue.field ? humaniseField(issue.field) : inferred.field ?? inferFieldFromType(issue.type) ?? inferFieldFromDetail(issue.detail); const locationLabel = effectiveSection ? sectionLabel(effectiveSection) : parseInstance(issue.instance); const key = getGroupingKey(issue, fieldLabel, effectiveSection); if (!groupMap.has(key)) { groupMap.set(key, { key, issue, issues: [], fieldLabel, locationLabel, effectiveSection, fixLink: null, // Will be resolved below affectedOrderIds: [], }); } const group = groupMap.get(key)!; group.issues.push(issue); // Collect unique order IDs if (issue.itemRef && isValidOrderId(issue.itemRef)) { const orderId = String(issue.itemRef); if (!group.affectedOrderIds.includes(orderId)) { group.affectedOrderIds.push(orderId); } } }); // Resolve fix links for each group (using the first issue) groupMap.forEach((group) => { const settingsUrl = getPluginSettingsPageUrl(); group.fixLink = resolveFixLink(group.issue, settingsUrl, group.effectiveSection); }); return Array.from(groupMap.values()); } export default function CheckoutErrorNotice({ error, settingsUrl, }: CheckoutErrorNoticeProps) { const resolvedSettingsUrl = settingsUrl ?? getPluginSettingsPageUrl(); const title = error.title ?? __('Could not process checkout', 'parcel2go-shipping'); const detail = error.detail ?? __('There was an issue processing your shipment.', 'parcel2go-shipping'); const issues = error.issues ?? []; const grouped = groupIssuesByKey(issues); const visibleGroups = grouped.slice(0, MAX_VISIBLE_GROUPS); const hiddenGroupCount = Math.max(0, grouped.length - MAX_VISIBLE_GROUPS); const totalHiddenIssues = issues.length - visibleGroups.reduce((sum, g) => sum + g.issues.length, 0); return ( {title}

{detail}

{issues.length > 0 && (
    {visibleGroups.map((group, i) => { const duplicateCount = group.issues.length; const multipleOrders = group.affectedOrderIds.length > 1; return (
  • {group.issue.title} {group.fieldLabel && group.locationLabel && ( <> {' '} {__('Fix', 'parcel2go-shipping')}{' '} {group.fieldLabel}{' '} {__('in', 'parcel2go-shipping')}{' '} {group.locationLabel} {duplicateCount > 1 && ( <> {' '} ({duplicateCount} {__('occurrences', 'parcel2go-shipping')}) )} . )} {!group.fieldLabel && !group.locationLabel && group.issue.detail && ( <> {': '} {group.issue.detail} {duplicateCount > 1 && ( <> {' '} ({duplicateCount} {__('occurrences', 'parcel2go-shipping')}) )} )} {multipleOrders && group.affectedOrderIds.length > 0 && ( <> {' '} {__('Affects orders:', 'parcel2go-shipping')}{' '} {group.affectedOrderIds.map((orderId, idx) => ( {idx > 0 && ', '} #{orderId} ))} )} {group.fixLink && !multipleOrders && ( <> {' '} {group.affectedOrderIds.length === 1 ? `${group.fixLink.label} #${group.affectedOrderIds[0]}` : group.fixLink.label} )}
  • ); })} {hiddenGroupCount > 0 && (
  • {hiddenGroupCount === 1 ? __('1 more issue found.', 'parcel2go-shipping') : `${hiddenGroupCount} ${__('more issues found.', 'parcel2go-shipping')}`}{' '} {__('Open the related order to review all details.', 'parcel2go-shipping')}
  • )}
)}
); }