/**
* 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'
)}
)}
);
}