# @nuskin/address-lookup

React address entry component with:
- Smarty autocomplete (US + international)
- optional Smarty verification
- Contentstack-backed form definitions
- required-field and email-format validation state
- localized field labels (Contentstack or parent-provided)

## Install

Using npm:

```bash
npm add @nuskin/address-lookup
```

Using yarn:

```bash
yarn add @nuskin/address-lookup
```

## Exports

```js
import AddressLookup, {
  normalizeAddressForm,
  SUPPORTED_COUNTRIES
} from '@nuskin/address-lookup'
```

- `AddressLookup` (default + named export)
- `normalizeAddressForm` and `DEFAULT_ADDRESS_FORM` for parent-side form-definition reuse
- `SUPPORTED_COUNTRIES` for the list of supported ISO2 market codes

## Basic usage

```jsx
import { useState } from 'react'
import AddressLookup from '@nuskin/address-lookup'

export default function ShippingAddress() {
  const [validationRequestId, setValidationRequestId] = useState(0)
  const [latest, setLatest] = useState(null)

  const handleSave = () => {
    // Triggers child to show validation UI + emit validation-request callback
    setValidationRequestId((n) => n + 1)
  }

  return (
    <>
      <AddressLookup
        country='US'
        language='en'
        requireValidation={true}
        validationRequestId={validationRequestId}
        onAddressSelect={setLatest}
      />

      <button onClick={handleSave}>Save</button>
      <pre>{JSON.stringify(latest, null, 2)}</pre>
    </>
  )
}
```

## Props

`AddressLookup` props:

- `onAddressSelect: (payload) => void`
- `country?: string` (default: `'US'`)
- `language?: string` (default: `'en'`)
- `address?: object | null` initial form values, including optional verification metadata
- `requireValidation?: boolean` (default: `true`)
- `readOnlyView?: boolean` (default: `false`)
- `showManualValidateButton?: boolean` (default: `false`)
- `validationRequestId?: number` (default: `0`)
- `strings?: object | null` optional label overrides
- `formDefinition?: object | null` optional normalized form-definition override

### `readOnlyView`

When `readOnlyView` is `true`, `AddressLookup` renders the passed-in address in display-only mode.

Behavior:

- all fields are rendered read-only
- no autocomplete API calls are made
- no verification API calls are made
- no initial auto-verification is triggered for incoming `unverified` addresses
- no map button is shown
- no validate button is shown
- no mode toggle is shown
- no autocomplete or verification suggestion lists are shown

### `strings` shape

If passed, these labels are used instead of loading from Contentstack.

The component accepts either:

1. a flat normalized label object
2. a raw Contentstack-style object with grouped validation strings

Flat normalized example:

```js
{
  address1Label: 'Street Address',
  address2Label: 'Apartment, suite, etc.',
  cityLabel: 'City',
  districtLabel: 'District',
  regionLabel: 'Region',
  postalCodeLabel: 'Postal Code',
  invalidEmail: 'Invalid email address.',
  fieldRequired: 'This field is required.',
  invalidPostalCode: 'Invalid postal code.',
  rateLimited: 'Too many requests. Please wait a moment and try again.',
  switchToAutocomplete: 'Switch to Autocomplete',
  switchToManualEntry: 'Switch to Manual Entry',
  addressLocation: 'Address location',
  openInMaps: 'Open in Google Maps',
  usVerified: 'Verified',
  usVerifiedMessage: 'This address is fully verified.',
  usNoResults: 'We couldn’t verify this address. Check the address and try again.',
  intlVerifiedTitle: 'Verified',
  intlVerifiedMessage: 'This address is verified.',
  intlNoResults: 'We couldn’t verify this address. Check the address and try again.'
}
```

Raw Contentstack-style grouped example:

```js
{
  address1Label: 'Street Address',
  address2Label: 'Apartment, suite, etc.',
  cityLabel: 'City',
  districtLabel: 'District',
  regionLabel: 'Region',
  postalCodeLabel: 'Postal Code',
  invalidPostalCode: 'Invalid postal code.',
  fieldRequired: 'This field is required.',
  rateLimited: 'Too many requests. Please wait a moment and try again.',
  switchToAutocomplete: 'Switch to Autocomplete',
  switchToManuelEntry: 'Switch to Manual Entry',
  addressLocation: 'Address location',
  openInMaps: 'Open in Google Maps',
  smartyUsValidationStrings: {
    usVerified: 'Verified',
    usVerifiedMessage: 'This address is fully verified.',
    usMissingSecondaryTitle: 'Apartment or suite needed',
    usMissingSecondaryMessage: 'The main address is valid, but a unit number is required.',
    usInvalidSecondaryRequiredTitle: 'Apartment or suite not recognized',
    usInvalidSecondaryRequiredMessage: 'The street address is valid, but the unit number was not recognized and is required.',
    usInvalidSecondaryOptionalTitle: 'Unit not recognized',
    usInvalidSecondaryOptionalMessage: 'The main address is valid, but the unit number was not recognized.',
    usVacantTitle: 'Verified, but marked vacant',
    usVacantMessage: 'This address is valid but marked as vacant.',
    usNoStatTitle: 'Verified with caution',
    usNoStatMessage: 'This address is valid, but USPS indicates limited delivery status.',
    usNotConfirmedTitle: 'Not verified',
    usNotConfirmedMessage: 'This address could not be confirmed.',
    usNoResults: 'We couldn’t verify this address. Check the address and try again.'
  },
  smartyIntlValidationStrings: {
    intlVerifiedTitle: 'Verified',
    intlVerifiedMessage: 'This address is verified.',
    intlVerifiedSubBuildingChangedTitle: 'Verified with unit changes',
    intlVerifiedSubBuildingChangedMessage: 'This address is verified, but the apartment or suite information was changed.',
    intlPartialTitle: 'Partially verified',
    intlPartialMessage: 'This address could only be partially verified.',
    intlAmbiguousTitle: 'Multiple matches found',
    intlAmbiguousMessage: 'Multiple possible matches were found for this address.',
    intlNotVerifiedTitle: 'Not verified',
    intlNotVerifiedMessage: 'This address could not be verified.',
    intlNoResults: 'We couldn’t verify this address. Check the address and try again.'
  }
}
```

Those grouped validation strings are normalized internally into the flat label shape used by the component.

For Contentstack-backed root fields, note that `switch_to_manuel_entry` is normalized to `switchToManualEntry` in the component label object.

### `formDefinition` shape

If passed, this is used instead of loading a form definition inside `AddressLookup`.

```js
{
  addressElements: [
    {
      field: 'address1',
      width: 'full',
      editable: true,
      maxLength: 40,
      required: true,
      apiMappings: {
        autocomplete: 'street',
        verification: 'formatted.address1'
      }
    }
  ],
  lookupField: 'address1',
  requiredFields: ['address1'],
  postalCodeRegex: /^\d{5}$/,
  verifyAfterAutocomplete: false,
  wrapAddress1ToAddress2: false
}
```

Notes:

- `apiMappings` is currently used on international forms
- valid verification mapping targets include direct Smarty paths like `components.locality` and derived targets like `formatted.address1` / `formatted.address2`
- US field mapping remains code-driven

If the parent is already loading form definitions from Contentstack, it can normalize them once and pass them through:

```js
import { normalizeAddressForm } from '@nuskin/address-lookup'
```

## Incoming address verification

The optional `address` prop can include verification metadata:

```js
{
  address1: '123 Main St',
  city: 'Provo',
  region: 'UT',
  postalCode: '84604',
  verification: {
    status: 'verified' | 'autocomplete' | 'unverified',
    source: 'smarty-us' | 'smarty-intl',
    verifiedAt: '2026-05-01T15:01:07.905Z',
    manuallyEdited: true,
    editedFields: ['address2']
  }
}
```

Behavior:

- incoming `verified` addresses are accepted as-is
- incoming `autocomplete` addresses are accepted as-is
- incoming `unverified` addresses are auto-verified once when `requireValidation=true` and enough address data is present
- `readOnlyView=true` suppresses that auto-verification behavior and displays the incoming address as-is
- if that auto-verification returns exactly one strong result, the component applies it automatically without user interaction
- after verification/autocomplete, minor editable-field changes preserve the current verification status and mark the address as manually edited
- major editable-field changes downgrade the address back to `unverified`

Major-field rule:

- `lookupField` is always major
- `address1`, `city`, `region`, and `postalCode` are major only when they are required in the resolved form definition
- fields like `address2` and `district` remain minor by default

## Callback payloads

`onAddressSelect(payload)` emits different `type` values:

- `autocomplete`
  - emitted when a suggestion is selected and mapped into form fields
- `field-change`
  - emitted on debounced manual edits
- `validation`
  - emitted when user selects a verification candidate
- `validation-request`
  - emitted when parent increments `validationRequestId`
  - reports current status only; does not trigger verification API calls

Common payload shape:

```js
{
  type: 'autocomplete' | 'field-change' | 'validation' | 'validation-request',
  address: {
    ...,
    verification?: {
      status: 'verified' | 'autocomplete' | 'unverified',
      source?: string,
      verifiedAt?: string,
      manuallyEdited?: boolean,
      editedFields?: string[]
    }
  },
  field?: string,
  value?: string,
  verification?: object,
  isDeliverable?: boolean,
  isValidEmail?: boolean,
  status: {
    formAvailable: boolean,
    verification: 'unverified' | 'autocomplete' | 'verified',
    manualChanges?: {
      manuallyEdited: true,
      editedFields: string[]
    },
    needsValidation: boolean,
    completeness: 'complete' | 'incomplete',
    missingFields: string[],
    mode: 'autocomplete' | 'manual',
    postalCode: {
      applicable: boolean,
      required: boolean,
      value: string,
      isValid: boolean | null
    },
    email: {
      applicable: boolean,
      required: boolean,
      value: string,
      isValid: boolean | null
    }
  }
}
```

For verified US and international validation results, `address` may also include:

```js
{
  latitude: 48.8566,
  longitude: 2.3522
}
```

These are returned from Smarty verify response metadata and are included when a validation candidate is selected.

When Google Maps is loaded by the parent app, the component can use these coordinates to show an in-app map modal from the address action row.

## Validation behavior

- Required fields come from the resolved form definition
- `status.formAvailable` is `false` when no form definition is available for the selected country
- `status.needsValidation` is `true` when:
  - `requireValidation=true`
  - mode is `manual`
  - address has not been verified yet
- Required errors are shown when:
  - field loses focus, or
  - parent triggers validation via `validationRequestId`
- UI for invalid required/email fields:
  - red input border
  - `aria-invalid="true"`
- Email format is validated when email has a non-empty value
- `status.completeness` is `incomplete` when email format is invalid
- Validation messages use localized strings when provided:
  - `fieldRequired`
  - `invalidPostalCode`
  - `invalidEmail`
  - `rateLimited`
- Safe defaults are used when those validation strings are missing
- verification results are cached briefly by exact request key:
  - US validation cache: 60 seconds
  - intl validation cache: 60 seconds
- failed validation requests are not cached

## Autocomplete and manual mode

- Lookup field comes from the resolved form definition
  - example: JP can use `postalCode`
- intl verification and map-triggered verification also use the configured `lookupField`, not always `address1`
- Typing in lookup creates a draft
- Clicking outside while suggestions are open cancels draft and restores prior committed values
- Manual mode:
  - with `requireValidation=true`, user can run verification explicitly
  - with `requireValidation=false`, no verification API call is made
  - parent `validationRequestId` returns current status but does not perform verification
  - optional `showManualValidateButton` renders a compact `Validate Address` button next to the lookup field actions

## Localization

When `strings` is not passed, labels load from Contentstack via `@nuskin/contentstack-lib` and are cached by locale key:

- cache key format: `${language}_${country}`

When `formDefinition` is not passed, the address form definition also loads from Contentstack.

If no form definition is available after loading, the component shows:

- `No form available for this country: US`
- `No form available for this country: {countryCode}`

## Styling and Overrides

The component now ships with a shared stylesheet:

- [src/styles/addressLookup.css](/Users/tsanderson/projects/nextgen/shipping/npm/address-lookup/src/styles/addressLookup.css)

Parent applications can skin the component in two ways:

1. Override exported class names in parent CSS
2. Override CSS variables on the root `.ns-address-lookup` element

Common class names:

- `.ns-address-lookup`
- `.ns-address-lookup__panel`
- `.ns-address-lookup__form`
- `.ns-address-lookup__field`
- `.ns-address-lookup__field--with-error`
- `.ns-address-lookup__label-row`
- `.ns-address-lookup__label`
- `.ns-address-lookup__actions`
- `.ns-address-lookup__toggle`
- `.ns-address-lookup__input`
- `.ns-address-lookup__input--readonly`
- `.ns-address-lookup__input--invalid`
- `.ns-address-lookup__input--loading`
- `.ns-address-lookup__error-text`
- `.ns-address-lookup__message`
- `.ns-address-lookup__spinner`
- `.ns-address-lookup__validation`
- `.ns-address-lookup__button`
- `.ns-address-lookup__button--compact`
- `.ns-address-lookup__list`
- `.ns-address-lookup__list--suggestions`
- `.ns-address-lookup__list--validation`
- `.ns-address-lookup__list--validation-inline`
- `.ns-address-lookup__list-item`
- `.ns-address-lookup__list-item--divided`
- `.ns-address-lookup__list-button`
- `.ns-address-lookup__list-title`
- `.ns-address-lookup__list-subtitle`

Supported CSS variables on `.ns-address-lookup`:

- `--nsal-text-color`
- `--nsal-muted-text`
- `--nsal-link-color`
- `--nsal-border-color`
- `--nsal-error-color`
- `--nsal-surface-color`
- `--nsal-disabled-surface`
- `--nsal-validation-surface`
- `--nsal-validation-border`
- `--nsal-shadow`
- `--nsal-radius`
- `--nsal-field-gap`
- `--nsal-field-gutter`
- `--nsal-error-gap`
- `--nsal-input-padding-y`
- `--nsal-input-padding-x`

Example:

```css
.checkout-address .ns-address-lookup {
  --nsal-border-color: #cbd5e1;
  --nsal-error-color: #b91c1c;
  --nsal-radius: 8px;
  --nsal-field-gutter: 16px;
}

.checkout-address .ns-address-lookup__button {
  background: #111827;
}
```

## Development

Run local test app:

```bash
npm run dev
```

Run checks:

```bash
npm run lint
npm test -- --runInBand
npm run build
```

## License

[MIT](https://opensource.org/licenses/MIT)
