/** * Settings page: fetches data, provides default values to forms, manages * hasFormChanged, discard (resets forms to default), and save (builds payload from form values). */ import { __ } from '@wordpress/i18n'; import { useState, useEffect, useCallback, useMemo, createInterpolateElement, } from '@wordpress/element'; import { Card, CardBody, CardHeader, Flex, Button, Notice, ToggleControl, Icon, } from '@wordpress/components'; import { Section, Spinner } from '@woocommerce/components'; import { useWindowSize, useSettings, useCountries, useParcel2goAuth, } from '../shared/hooks'; import type { DefaultShippingSettings, PackagesFormValue, TaxSettings, DefaultSettingsOverrides, } from '../types'; import { DefaultShippingForm, PackagePresetsForm, TaxForm, DefaultSettingsOverridesForm, DefaultServicesForm, defaultShippingSchema, taxSettingsSchema, defaultSettingsOverridesSchema, getSectionFieldErrors, packagePresetSchema, } from '../features/settings'; import type { SettingsFieldErrors } from '../features/settings/utils/getSettingsFieldErrors'; import { Badge } from '../shared/components'; import { getShippingZonesUrl, getLandingPageUrl } from '../shared/utils'; import { UpdateSettingsPayload } from '../types/settings'; import LoggingCard from '../features/settings/logging-card'; type EditableSectionId = 'shipping' | 'packages' | 'tax' | 'settings-overrides'; type SectionId = EditableSectionId | 'default-services'; const SECTION_IDS: SectionId[] = [ 'shipping', 'packages', 'tax', 'settings-overrides', 'default-services', ]; function isSectionId(value: string | null | undefined): value is SectionId { return value != null && SECTION_IDS.includes(value as SectionId); } function getSectionIdFromHash(): SectionId | null { if (typeof window === 'undefined') return null; const hash = window.location.hash.replace(/^#/, ''); return isSectionId(hash) ? hash : null; } const EORI_LINK = 'https://www.gov.uk/eori'; const SHIPPING_ZONES_DOC_URL = 'https://woocommerce.com/document/setting-up-shipping-zones/'; const SECTION_LIST: { id: SectionId; title: string; description: string | React.ReactNode; }[] = [ { id: 'shipping', title: __('Default shipping details', 'parcel2go-shipping'), description: __( 'Set your return and/or collection address. This will appear on your shipping labels. This address will be used instead of the default address for your store.', 'parcel2go-shipping' ), }, { id: 'packages', title: __('Default package settings', 'parcel2go-shipping'), description: __( 'Manage the package settings for your app.', 'parcel2go-shipping' ), }, { id: 'tax', title: __('Default tax numbers', 'parcel2go-shipping'), description: createInterpolateElement( __( 'Manage the tax numbers for your app. These settings are required if you are exporting goods. Get your EORI number from HMRC.', 'parcel2go-shipping' ), { link: ( HMRC ), } ), }, { id: 'settings-overrides', title: __('Default settings overrides', 'parcel2go-shipping'), description: __( 'Set default values for weight, tariff codes, and country of manufacture for your products. These settings are used if your products do not have a weight, tariff code, or country of manufacture set.', 'parcel2go-shipping' ), }, { id: 'default-services', title: __('Default services', 'parcel2go-shipping'), description: __( 'Automatically apply your preferred delivery service to new orders — no extra steps needed. Create or edit shipping zones in WooCommerce, or read the guide to shipping zones.', 'parcel2go-shipping' ), }, ]; const VAT_STATUS_OPTIONS = [ { value: '', label: __('— Select —', 'parcel2go-shipping') }, { value: 'individual', label: __('Individual', 'parcel2go-shipping') }, { value: 'business-registered', label: __('Registered', 'parcel2go-shipping'), }, { value: 'business-not-registered', label: __('Unregistered', 'parcel2go-shipping'), }, ]; const DEFAULT_SHIPPING: DefaultShippingSettings = { contactName: '', organisation: '', property: '', street: '', town: '', postcode: '', county: '', country: '', email: '', phone: '', }; const DEFAULT_TAX: TaxSettings = { vatNumber: '', eori: '', vatStatus: 'individual', }; const DEFAULT_OVERRIDES: DefaultSettingsOverrides = { defaultWeightKg: 1, defaultTariffCode: '', defaultCountryOfManufacture: '', }; const NEW_PACKAGE_EMPTY = { name: '', length: 0, width: 0, height: 0, }; export default function SettingsPage() { const { settings, loading, error, saveOnboardingSettings, saveP2GSettings, } = useSettings(); const { countries, loading: countriesLoading } = useCountries(); const { isLinked, startLogin, error: loginError, isLoading: loginLoading, } = useParcel2goAuth(); const { width } = useWindowSize(); const isDesktop = width >= 783; // Derive defaultServiceZones from unified settings const defaultServiceZones = settings?.defaultServiceZones ?? []; const defaultServicesLoading = loading; const defaultServicesError = error; const [selectedSection, setSelectedSection] = useState( () => getSectionIdFromHash() ?? 'shipping' ); const [formValues, setFormValues] = useState< Partial< Record< EditableSectionId, | DefaultShippingSettings | PackagesFormValue | TaxSettings | DefaultSettingsOverrides > > >({}); const [hasFormChanged, setHasFormChanged] = useState(false); const [resetTrigger, setResetTrigger] = useState(0); const [saving, setSaving] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false); const [saveMessage, setSaveMessage] = useState(null); const [submitErrors, setSubmitErrors] = useState(null); const [onboardingToggleLoading, setOnboardingToggleLoading] = useState(false); const [debouncedFormValues, setDebouncedFormValues] = useState(formValues); useEffect(() => { const id = setTimeout(() => setDebouncedFormValues(formValues), 400); return () => clearTimeout(id); }, [formValues]); // Normalize country code to ISO2, accepting ISO2/ISO3 in any case. const normalizeCountryCode = useCallback( (code: string): string => { if (!code) return ''; const normalizedInput = code.trim().toUpperCase(); if (!normalizedInput) return ''; const direct = countries.find( (c) => c.iso2Code.toUpperCase() === normalizedInput || c.iso3Code.toUpperCase() === normalizedInput ); if (direct) { return direct.iso2Code; } const redirected = countries.find((c) => (c.redirects ?? []).some( (r) => r.iso2Code.toUpperCase() === normalizedInput || r.iso3Code.toUpperCase() === normalizedInput ) ); if (redirected) { return redirected.iso2Code; } return normalizedInput; }, [countries] ); const countryOptions = useMemo( () => [ { value: '', label: __('— Select country —', 'parcel2go-shipping'), }, ...countries.map((c) => ({ value: c.iso2Code, label: c.name })), ], [countries] ); // Default values per section (from fetched settings). const defaults = useMemo(() => { if (!settings) return null; return { shipping: { ...DEFAULT_SHIPPING, ...settings.shipping, country: normalizeCountryCode(settings.shipping?.country || ''), }, packages: { packages: settings.packages.map((p) => ({ ...p })), defaultPackageId: settings.defaultPackageId ?? '', newPackage: NEW_PACKAGE_EMPTY, }, tax: { ...DEFAULT_TAX, ...settings.tax }, 'settings-overrides': { ...DEFAULT_OVERRIDES, ...settings.overrides, defaultCountryOfManufacture: normalizeCountryCode( settings.overrides?.defaultCountryOfManufacture || '' ), }, }; }, [settings, normalizeCountryCode]); const defaultPackage = useMemo(() => { if ( !defaults?.packages.packages.length || !defaults.packages.defaultPackageId ) return null; return ( defaults.packages.packages.find( (p) => p.id === defaults.packages.defaultPackageId ) ?? null ); }, [defaults]); // Effective values (form overrides merged with defaults) for completion calculation. const effectiveValues = useMemo(() => { if (!defaults) return null; return { shipping: (debouncedFormValues.shipping ?? defaults.shipping) as DefaultShippingSettings, packages: (debouncedFormValues.packages as | PackagesFormValue | undefined) ?? defaults.packages, tax: (debouncedFormValues.tax ?? defaults.tax) as TaxSettings, 'settings-overrides': (debouncedFormValues['settings-overrides'] as | DefaultSettingsOverrides | undefined) ?? defaults['settings-overrides'], }; }, [defaults, debouncedFormValues]); // Section completion: 0–100 per section (which fields are filled). const sectionCompletion = useMemo((): Record => { if (!effectiveValues) return { shipping: 0, packages: 0, tax: 0, 'settings-overrides': 0, 'default-services': 0, }; const shipping = effectiveValues.shipping; const shippingFilled = [ shipping.contactName, shipping.organisation, shipping.street, shipping.town, shipping.postcode, shipping.county, shipping.country, shipping.email, shipping.phone, ].filter((v) => typeof v === 'string' && v.trim() !== '').length; const shippingPct = Math.round((shippingFilled / 9) * 100); const packages = effectiveValues.packages; const validPackages = packages.packages.filter( (pkg) => pkg.name?.trim() !== '' && pkg.length > 0 && pkg.width > 0 && pkg.height > 0 ); const hasAnyPackage = validPackages.length > 0; const hasDefault = !!packages.defaultPackageId && packages.packages.some( (pkg) => pkg.id === packages.defaultPackageId ); // Progress: 0% if no packages; partial if packages but no default; 100% if has default. const packagesPct = !hasAnyPackage ? 0 : hasDefault ? 100 : 40; const tax = effectiveValues.tax; const taxFilled = [tax.vatNumber, tax.eori, tax.vatStatus].filter( (v) => typeof v === 'string' && v.trim() !== '' ).length; const taxPct = Math.round((taxFilled / 3) * 100); const overrides = effectiveValues['settings-overrides']; const overridesFilled = [ overrides.defaultWeightKg > 0, (overrides.defaultTariffCode ?? '').trim() !== '', (overrides.defaultCountryOfManufacture ?? '').trim() !== '', ].filter(Boolean).length; const overridesPct = Math.round((overridesFilled / 3) * 100); const defaultServiceMethodIds = defaultServiceZones .flatMap((zone) => zone.methods.map((method) => method.id)) .filter((id): id is string => Boolean(id)); const configuredDefaultServiceCount = defaultServiceMethodIds.filter( (methodId) => (settings?.defaultServiceCouriers?.[methodId] ?? []).length > 0 ).length; const defaultServicesPct = defaultServiceMethodIds.length === 0 ? 0 : Math.round( (configuredDefaultServiceCount / defaultServiceMethodIds.length) * 100 ); return { shipping: Math.min(100, shippingPct), packages: Math.min(100, packagesPct), tax: Math.min(100, taxPct), 'settings-overrides': Math.min(100, overridesPct), 'default-services': Math.min(100, defaultServicesPct), }; }, [ effectiveValues, defaultServiceZones, settings?.defaultServiceCouriers, ]); const showSetupGuide = settings?.onboarding?.dismissed !== true; const handleToggleSetupGuide = useCallback( async (checked: boolean) => { setOnboardingToggleLoading(true); try { await saveOnboardingSettings({ onboarding: { dismissed: !checked, currentStep: settings?.onboarding?.currentStep ?? 1, }, }); } finally { setOnboardingToggleLoading(false); } }, [saveOnboardingSettings, settings?.onboarding?.currentStep] ); useEffect(() => { if (!hasFormChanged) return; const handleBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [hasFormChanged]); const handleFormChange = useCallback( ( formId: string, value: | DefaultShippingSettings | PackagesFormValue | TaxSettings | DefaultSettingsOverrides ) => { setFormValues((prev) => ({ ...prev, [formId as EditableSectionId]: value, })); }, [] ); const handleInteraction = useCallback(() => { setHasFormChanged(true); }, []); const discardChanges = useCallback(() => { setFormValues({}); setHasFormChanged(false); setResetTrigger((t) => t + 1); setSubmitErrors(null); setShowSectionSwitchBlockNotice(false); }, []); const handleSave = useCallback(async () => { if (!defaults) return; if (selectedSection === 'default-services') return; const section = selectedSection as EditableSectionId; const shipping = (formValues.shipping ?? defaults.shipping) as DefaultShippingSettings; const tax = (formValues.tax ?? defaults.tax) as TaxSettings; const packagesForm: PackagesFormValue = (formValues.packages as PackagesFormValue | undefined) ?? defaults.packages; const overrides: DefaultSettingsOverrides = (formValues['settings-overrides'] as | DefaultSettingsOverrides | undefined) ?? defaults['settings-overrides']; let payload: UpdateSettingsPayload | null = null; if (section === 'shipping') { const parsed = defaultShippingSchema.safeParse(shipping); if (!parsed.success) { setSubmitErrors({ shipping: getSectionFieldErrors(parsed.error), packages: {}, tax: {}, overrides: {}, }); return; } payload = { type: 'Address', data: { nickname: parsed.data.contactName, name: parsed.data.contactName, organisation: parsed.data.organisation, property: parsed.data.property, street: parsed.data.street, town: parsed.data.town, postcode: parsed.data.postcode, county: parsed.data.county, country: normalizeCountryCode(parsed.data.country), email: parsed.data.email, phone: parsed.data.phone, isDefault: true, isBilling: true, locality: parsed.data.town, }, }; } else if (section === 'packages') { const parsed = packagePresetSchema.safeParse({ name: packagesForm.newPackage.name, length: packagesForm.newPackage.length, width: packagesForm.newPackage.width, height: packagesForm.newPackage.height, }); if (!parsed.success) { const sectionErrors = getSectionFieldErrors(parsed.error); const packageErrors = Object.fromEntries( Object.entries(sectionErrors).map(([key, message]) => [ `new.${key}`, message, ]) ); setSubmitErrors({ shipping: {}, packages: packageErrors, tax: {}, overrides: {}, }); return; } payload = { type: 'Packages', data: { name: parsed.data.name, length: parsed.data.length, width: parsed.data.width, height: parsed.data.height, weight: 0, description: parsed.data.name, packageTypeId: 1, }, }; } else if (section === 'tax') { const parsed = taxSettingsSchema.safeParse(tax); if (!parsed.success) { setSubmitErrors({ shipping: {}, packages: {}, tax: getSectionFieldErrors(parsed.error), overrides: {}, }); return; } payload = { type: 'App', data: { taxNumbers: { vatNumber: parsed.data.vatNumber, eoriNumber: parsed.data.eori, vatStatus: parsed.data.vatStatus, }, }, }; } else if (section === 'settings-overrides') { const parsed = defaultSettingsOverridesSchema.safeParse(overrides); if (!parsed.success) { setSubmitErrors({ shipping: {}, packages: {}, tax: {}, overrides: getSectionFieldErrors(parsed.error), }); return; } payload = { type: 'App', data: { defaultSettings: { defaultWeight: parsed.data.defaultWeightKg, defaultTariffCode: parsed.data.defaultTariffCode, defaultCountryOfManufacture: normalizeCountryCode( parsed.data.defaultCountryOfManufacture ), }, }, }; } if (!payload) return; setSubmitErrors(null); setSaving(true); setSaveSuccess(false); setSaveMessage(null); try { const result = await saveP2GSettings(payload); if (result.success) { setSaveSuccess(true); setSaveMessage(result.message); setHasFormChanged(false); setFormValues({}); setShowSectionSwitchBlockNotice(false); } else { setSaveSuccess(false); setSaveMessage(result.message); } } catch { // Error state and message are set by useSettings; show them in the UI } finally { setSaving(false); } }, [ defaults, formValues, normalizeCountryCode, saveP2GSettings, selectedSection, ]); const canSave = hasFormChanged; const [showSectionSwitchBlockNotice, setShowSectionSwitchBlockNotice] = useState(false); // Update URL hash when section changes useEffect(() => { if (typeof window === 'undefined') return; const newHash = `#${selectedSection}`; if (window.location.hash !== newHash) { window.history.replaceState(null, '', newHash); } }, [selectedSection]); const handleSectionClick = useCallback( (sectionId: SectionId) => { if (hasFormChanged && sectionId !== selectedSection) { setShowSectionSwitchBlockNotice(true); return; } setShowSectionSwitchBlockNotice(false); setSelectedSection(sectionId); }, [hasFormChanged, selectedSection] ); const sectionListForRender = useMemo( () => SECTION_LIST.map((section) => { if (section.id !== 'default-services') return section; return { ...section, description: createInterpolateElement( section.description as string, { createLink: ( {__( 'Create or edit shipping zones', 'parcel2go-shipping' )} ), docLink: ( {__( 'guide to shipping zones', 'parcel2go-shipping' )} ), } ), }; }), [] ); if (loading || !defaults) { return (
); } const sectionListContent = isDesktop ? ( {sectionListForRender.map((section) => { const pct = sectionCompletion[section.id]; return ( handleSectionClick(section.id)} style={{ cursor: 'pointer', background: selectedSection === section.id ? '#fff' : 'transparent', boxShadow: selectedSection === section.id ? '0 1px 3px rgba(0,0,0,0.06)' : 'none', borderRadius: 0, }} >
{section.title}
= 100 ? 'success' : 'default' } > {pct}%
{section.description}
); })}
) : ( {sectionListForRender.map((section) => { const pct = sectionCompletion[section.id]; return ( ); })} ); const renderFormForSection = (sectionId: SectionId) => { const storeDimensionUnit = settings?.units?.dimension || 'cm'; const storeWeightUnit = settings?.units?.weight || 'kg'; switch (sectionId) { case 'shipping': return ( ); case 'packages': return ( ); case 'tax': return ( ); case 'settings-overrides': return ( ); case 'default-services': return ( ); default: return null; } }; const saveButton = ( {hasFormChanged && ( )} ); return (

{__('Settings', 'parcel2go-shipping')}

{!isLinked ? ( ) : ( <> {error && ( {error} )} {saveSuccess && saveMessage && ( {saveMessage} )} {!saveSuccess && saveMessage && ( {saveMessage} )} {hasFormChanged && ( {__( 'You have unsaved changes. Save your changes or they may be lost.', 'parcel2go-shipping' )} )} {showSectionSwitchBlockNotice && ( setShowSectionSwitchBlockNotice(false) } > {__( 'Save or discard your changes before switching sections.', 'parcel2go-shipping' )} )} {isDesktop ? (
{sectionListContent}
{renderFormForSection(selectedSection)}
{saveButton}
) : ( {sectionListForRender.map((section) => ( {section.title}

{section.description}

{renderFormForSection(section.id)}
))} {saveButton}
)} )} {/* Onboarding section */} {__('Quick setup guide', 'parcel2go-shipping')} {onboardingToggleLoading && ( {__( 'Saving setting…', 'parcel2go-shipping' )} )}
); }