diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dee0e4..d0efbfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,7 @@ jobs: - run: npm test - run: npm run build + + - run: npx playwright install --with-deps chromium + + - run: npx playwright test diff --git a/.gitignore b/.gitignore index fbc7087..11d1e95 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,11 @@ dist/ .eslintcache .stylelintcache +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ + # Output of 'npm pack' *.tgz diff --git a/.prettierignore b/.prettierignore index 54085f2..ce4bb8f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,4 +7,7 @@ coverage dist/ README.md package.json -package-lock.json \ No newline at end of file +package-lock.json + +# Reference prototypes — reimplementation source, not shippable code (see CLAUDE.md). +design/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index e3c674d..0395d2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a TypeScript library for Smarty address autocomplete and validation. The main entry point is the `SmartyAddress` class exported from `src/index.ts`. +### Address Verification (in design) + +Verification (Smarty US/International Street API) is specced but not yet built. Source of truth: + +- `PRD-address-verification.md` — requirements; §7 is the locked result taxonomy. +- `design/verification/` — interactive HTML/JS prototypes of the result UI. Open `Generic Result Surfacing.html` in a browser; read `chats/` for intent. `app/result-types.jsx` encodes the §7 taxonomy as data. + +These are prototypes to reimplement, not shippable code. Defer concrete config keys / service names until the engineering spike (PRD §12). + ### Class-Based Service Architecture The codebase uses ES6 classes for all services. Each service extends `BaseService` from `src/services/BaseService.ts`, which provides: diff --git a/ERD-address-verification.md b/ERD-address-verification.md new file mode 100644 index 0000000..5dd7a66 --- /dev/null +++ b/ERD-address-verification.md @@ -0,0 +1,490 @@ +# ERD: Address Verification for smarty-address + +**Status:** Draft — engineering design derived from `PRD-address-verification.md` +**Source of truth:** `PRD-address-verification.md` (requirements) + `release-structure-address-verification.md` (sequencing). §-refs below point at the PRD unless prefixed `RS§` (release structure). +**Scope of this doc:** the **engineering spike output** the PRD calls for in §12.3 / RS Epic 0 — exact config keys, types, hook contracts, the "current address" abstraction, submission-blocking strategy, staleness behavior, per-country threshold, and CSS variable list. It is the implementation plan that supersedes the scoping plan. + +> **What this doc is and isn't.** The PRD owns _what_ and _why_ (and is deliberately non-committal on UX and defaults until prototypes settle). This ERD owns _how_: concrete TypeScript surfaces, control flow, file-by-file changes, and the test matrix. Where the PRD marks something _provisional / TBD / deferred_, this doc encodes it as a **one-line-changeable constant or config default** (§2 goal: defaults are a one-line change) rather than hard-coding it — so locking the API does not require locking the defaults. + +--- + +## 1. Architectural Overview + +Verification is an **additive** subsystem alongside autocomplete. It introduces one new service, one new config block, one new family of top-level types, and an optional UI layer. Nothing in the existing autocomplete path changes behaviorally; the only edits to existing files are additive (new config keys, new service registration, new CSS variables). + +``` + SmartyAddress (src/index.ts) + │ + ┌──────────────────────────┼───────────────────────────┐ + │ │ │ + ApiService VerificationService FormService + (autocomplete: (NEW — Street API: (field population; + US Pro + Intl v2) US Street + Intl Street) correction round-trip) + │ │ │ + └────── shared: auth (embeddedKey), transport (fetch), error taxonomy ──────┘ + │ + CurrentAddress (NEW abstraction) + one address-under-work, regardless of source + │ + VerificationUiService (NEW — badge / aria / panel / chooser) + follows DropdownService.announce() aria-live pattern +``` + +### 1.1 Service boundary (PRD §5 "Service boundary") + +`VerificationService extends BaseService` — a **sibling** of `ApiService`, not a subclass. They share only auth (`embeddedKey`) and transport (`fetch`) and the error-name taxonomy. Rationale: the Street APIs are a different request/response shape, a different billing surface, and a different failure-handling contract (fail-open) than autocomplete; coupling them would force the "verification can run with no autocomplete" mode (PRD §4 mode 2) to drag autocomplete machinery it never uses. + +Shared transport is extracted into a thin internal helper rather than inherited, so neither service is the other's base: + +- **Option chosen:** a free function module `src/services/http/streetTransport.ts` (or reuse a shared `fetchJson` helper) that both services call. Auth key is passed in per-call from each service's own `init()`-stored `embeddedKey`. No shared base beyond `BaseService`. +- **Rejected:** `VerificationService extends ApiService` — violates PRD §5 and the standalone-mode requirement. + +### 1.2 New services registered (`SmartyAddress.services`, `src/index.ts`) + +| Service | Responsibility | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VerificationService` | Street API calls (US + Intl), response normalization, result **classification** into the §7 taxonomy, behavior dispatch, dedupe, staleness tracking. | +| `VerificationUiService` | The optional UI surfaces (badge, aria-only, panel, ambiguous chooser). Themed via CSS variables. Follows `DropdownService` DOM-creation + `announce()` style. | + +Both are instantiated in the `SmartyAddress` constructor, wired via `setServices()`, and `init(mergedConfig)`'d in `SmartyAddress.init`, exactly like the existing nine services. `ServiceDependencies` (BaseService.ts) and `ServiceClassOverrides` (interfaces.ts) gain `verificationService?` / `VerificationService?` (+ UI) entries so the existing service-override mechanism (CLAUDE.md "Service Overrides") works for verification too. + +--- + +## 2. Operating Modes & Construction (PRD §4) + +`SmartyAddress.init` resolves the three modes from config: + +```ts +const autocompleteOn = config.autocomplete?.enabled ?? true; // provisional default §6 +const verificationOn = config.verification?.enabled ?? true; // PROVISIONAL — see §9 / RS Epic 5 gate +``` + +- **Neither on** → `validateConfig` warns (`console.warn`) and the plugin no-ops: no listeners attached, no services initialized beyond construction. (PRD §4 last paragraph.) +- **Autocomplete only** → today's path, untouched. `VerificationService.init` is skipped (or inits to an inert state). +- **Verification only** → `DropdownService` is **not** initialized; `VerificationService` + `VerificationUiService` init. No `address_id` anchor exists (Q8); verify operates on free-form/pasted field values. +- **Both** → full path. Selection feeds `CurrentAddress`, which verification consumes. + +> **De-skew note (Q5, RS Epic 1).** Today the top-level config _is_ the autocomplete config (`streetSelector`, `embeddedKey`, etc. live at the root — interfaces.ts). We introduce a nested `autocomplete` block AND keep every existing root key working as an alias. `normalizeConfig` (src/utils/configNormalizer) folds root-level autocomplete keys into `config.autocomplete.*`. **Additive only — no existing key is removed or repurposed.** This is the one structural change to the existing surface and it is backward-compatible. + +--- + +## 3. Configuration Schema + +New `verification` block on `SmartyAddressConfig` (interfaces.ts). All keys optional; every default is a single constant in `src/constants.ts` (or a `defaultVerificationConfig` literal) so it is a one-line change per §2 / §6. + +```ts +export interface VerificationConfig { + enabled?: boolean; // provisional default true (§6, gated RS Epic 5) + + // WHEN verification fires (§5 "Trigger", §6) + trigger?: VerificationTrigger[]; // default ["selection", "blur"] + // "selection" — after an autocomplete pick + // "blur" — street/last field loses focus (handles free-form, §4 mode 2) + // "submit" — pre-submit hook (Epic 3); await-able + // "manual" — only via smartyAddress.verify() + + // WHAT happens per result KIND (§7). Not one global switch. + onResult?: Partial>; + + // UI surface (§5 "UX surface", §6) + ui?: "none" | "aria-only" | "badge" | "panel"; // default per §6: badge/cue + + failureMode?: "fail-open" | "fail-closed"; // default "fail-open" (§6) + fieldLevelHighlighting?: boolean; // default false / atomic (§6) + + correctionPrompt?: { style?: "inline-note" | "did-you-mean" | "silent-swap" }; // TBD Q3 + + // Street API endpoints (parallel to the autocomplete URLs in constants.ts) + usStreetApiUrl?: string; // default US_STREET_API_URL + internationalStreetApiUrl?: string; // default INTERNATIONAL_STREET_API_URL + + // Hooks (§9 Q2) — see §7 + onVerified?: (result: VerificationResult) => void | Promise; + onVerificationFailed?: (error: VerificationError) => void | Promise; + onCorrectionOffered?: ( + diff: AddressDiff, + result: VerificationResult, + ) => void | VerificationDecision | Promise; + onBeforeSubmit?: (result: VerificationResult | null) => boolean | Promise; // Epic 3; resolve(false) blocks submit +} +``` + +```ts +type VerificationTrigger = "selection" | "blur" | "submit" | "manual"; +type VerificationBehavior = + | "silent" + | "apply-and-notify" + | "prompt" + | "apply-primary" + | "warn" + | "block" + | "ignore" + | "first-candidate"; +type VerificationResultKey = + | "verified" + | "corrected" + | "missingSecondary" + | "secondaryNotMatched" + | "flagged" + | "ambiguous" + | "undeliverable" + | "error"; +``` + +### 3.1 Per-type behavior defaults (PRD §7 taxonomy + per-type override shape) + +These map 1:1 to `design/verification/app/result-types.jsx` (the locked §7 source of truth). Defaults live in `defaultVerificationConfig.onResult`: + +| `VerificationResultKey` | Type # | Default behavior | Allowed overrides | Release | +| ----------------------- | ------ | ------------------------- | ---------------------------- | ---------------------------- | +| `verified` | 1 | `silent` | — | R1 | +| `corrected` | 2 | `apply-and-notify` | `silent` · `prompt` | R1 | +| `missingSecondary` | 3 | `prompt` | `ignore` | R1 | +| `secondaryNotMatched` | 4 | `prompt` | `apply-primary` · `ignore` | R1 | +| `flagged` | 5 | `warn` | `silent` | R1 | +| `ambiguous` | 6 | `prompt` | `first-candidate` · `ignore` | **R2** | +| `undeliverable` | 7 | `warn` | `block` · `silent` | R1 (warn) / **R3** (`block`) | +| `error` | 8 | governed by `failureMode` | — | R1 | + +> **Validation guard.** `validateConfig` rejects a `block` override on any type before Epic 3 ships it, and rejects `ui: "panel"` / `onResult.ambiguous` before Epic 2 — with a clear "not yet supported in this version" warning rather than silent no-op. This keeps each release self-contained (RS "usable on its own") without pretending to support unbuilt behaviors. + +--- + +## 4. Type System (PRD §8) + +New top-level types in `src/interfaces.ts` (the PRD §14 designated home). `VerificationResult` is **its own top-level structure, not nested in `AutocompleteSuggestion`** (PRD §5 "Result storage"). + +```ts +// Deliverability, normalized across US + Intl into one enum the behavior +// dispatcher keys on. Raw signals preserved in `.raw` for debugging/hooks. +export type DeliverabilityCode = + | "deliverable" // type 1/2 + | "deliverable-missing-secondary" // type 3 + | "deliverable-bad-secondary" // type 4 + | "deliverable-flagged" // type 5 + | "ambiguous" // type 6 + | "undeliverable" // type 7 + | "unknown"; // type 8 (error) + +export interface AddressDiff { + // field key -> {from, to}; only changed fields present. + // honors verification.fieldLevelHighlighting (atomic vs per-field). + changes: Partial>; + changedFields: AddressField[]; +} +type AddressField = + | "street" + | "secondary" + | "locality" + | "administrativeArea" + | "postalCode" + | "country"; + +export interface VerificationResult { + type: VerificationResultKey; // the §7 classification (drives behavior) + code: DeliverabilityCode; + entered: CurrentAddress; // what the user had + corrected: CurrentAddress | null; // standardized form (null for 6/7/8) + diff: AddressDiff | null; // entered -> corrected + candidates?: CurrentAddress[]; // type 6 only + nonBlocking: boolean; // types 5,7,8 -> never gate submit + raw: UsStreetResult | InternationalStreetResult; // untouched API payload + source: "us" | "international"; +} + +export interface VerificationError { + kind: "network" | "auth" | "quota" | "parse" | "unknown"; + message: string; + failureMode: "fail-open" | "fail-closed"; + cause?: unknown; +} + +// Result of a hook/chooser deciding what to do with a correction/candidate. +export interface VerificationDecision { + action: "accept" | "reject" | "choose"; + chosen?: CurrentAddress; // for action "choose" (ambiguous/did-you-mean) +} +``` + +### 4.1 The "current address" abstraction (PRD §8, §9 Q6) + +A single concept for _the address being worked with_, regardless of where it came from. This is the seam that lets verification run identically in all three modes. + +```ts +export interface CurrentAddress { + street: string; + secondary: string; + locality: string; // city + administrativeArea: string; // state / region / province + postalCode: string; + country: string; // resolved ISO code + + origin: "autocomplete" | "free-form" | "verification"; + address_id?: string; // present only when origin === "autocomplete" (Intl anchor) + verifiedAt?: number; // set after a successful verify; cleared on staleness +} +``` + +- **Why a new type rather than reuse `AutocompleteSuggestion`:** `AutocompleteSuggestion` (interfaces.ts:119) is shaped by the autocomplete responses (`street_line`, `entries`, `metadata`) and only exists when a dropdown ran. Verification must work from raw form fields with no suggestion. `CurrentAddress` is the normalization target for **all** sources; adapters convert into it: + - `fromSuggestion(s: AutocompleteSuggestion): CurrentAddress` + - `fromFormFields(selectors, domService): CurrentAddress` — reads the configured `*Selector` fields (verification-only / free-form path, Q8). + - `fromVerification(r: VerificationResult): CurrentAddress` — the corrected address becomes the new current address. +- **FormService coupling (Q6):** `FormService.populateFormWithAddress` currently takes an `AutocompleteSuggestion` (FormService.ts:120). We add `populateFormWithCurrentAddress(addr: CurrentAddress)` (or generalize the existing method to accept either via an adapter). Corrections round-trip through here (PRD §14). `getStreetFormValue` / single-field handling already exist and are reused. + +--- + +## 5. VerificationService — API & Control Flow + +### 5.1 Public surface + +```ts +class VerificationService extends BaseService { + init(config: NormalizedSmartyAddressConfig): void; // stores embeddedKey, urls, verification cfg + + // The one manual / standalone entry point (PRD §8 "standalone verify()"). + // Exposed on the instance as smartyAddress.verify(address?). + async verify(address?: CurrentAddress | Partial): Promise; + + // Internal trigger entry points (wired by the orchestrator): + async verifyCurrent(trigger: VerificationTrigger): Promise; + + classify( + raw: UsStreetResult | InternationalStreetResult, + entered: CurrentAddress, + ): VerificationResult; + isStale(addr: CurrentAddress): boolean; +} +``` + +`smartyAddress.verify(address?)` is added as a public method on the `SmartyAddress` class (`src/index.ts`) that delegates to `verificationService.verify`. When `address` is omitted it reads current form fields via the `fromFormFields` adapter. + +### 5.2 Street API request contract + +New constants in `src/constants.ts` (parallel to the existing autocomplete URLs): + +```ts +export const US_STREET_API_URL = "https://us-street.api.smarty.com/street-address"; +export const INTERNATIONAL_STREET_API_URL = "https://international-street.api.smarty.com/verify"; +``` + +Both authenticate with the same embedded-key mechanism as autocomplete (`auth-id` / `key` query param + the existing `USER_AGENT` string from ApiService.ts:17). Reuse the `USER_AGENT` constant. + +- **US Street:** single GET, candidate array response. Key response fields consumed (§7): `analysis.dpv_match_code`, `analysis.dpv_vacant`, `analysis.dpv_no_stat`, `analysis.footnotes`, `analysis.dpv_cmra`, the standardized `components` + `delivery_line_1`/`last_line`. +- **International Street:** GET; response carries `analysis.verification_status`, `analysis.address_precision`, `analysis.max_address_precision`, and per-component `analysis.changes.*`. + +### 5.3 International ordering (PRD §5 "International ordering") + +Detail fetch **first**, then verify — so verification has full components. In "both" mode an autocomplete international selection already triggers `ApiService.fetchInternationalAddressDetail` (ApiService.ts:241); the resulting fully-populated `CurrentAddress` (with `address_id`) is what `VerificationService.verify` receives. Sequencing: + +``` +selection (intl) → ApiService.fetchInternationalAddressDetail → CurrentAddress(full) + → VerificationService.verify → International Street API +``` + +### 5.4 Classification (`classify`) — the heart of §7 + +Pure function: raw API payload + entered address → `VerificationResult`. Two branch tables, one per `source`, both emitting the same `VerificationResultKey`. Logic transcribed directly from §7 / result-types.jsx: + +**US** (`dpv_match_code` + footnotes): + +| Condition | → type | +| -------------------------------------------------------------------------------------------------------------- | ------------------------- | +| zero candidates **or** `dpv_match_code === "N"` | `undeliverable` (7) | +| `dpv_match_code ∈ {Y,S,D}` **and** (`dpv_vacant==="Y"` ∥ `dpv_no_stat==="Y"` ∥ footnote `R7`) | `flagged` (5) | +| `dpv_match_code === "D"` (footnote `N1`) | `missingSecondary` (3) | +| `dpv_match_code === "S"` | `secondaryNotMatched` (4) | +| multiple candidates returned | `ambiguous` (6) | +| `dpv_match_code ∈ {Y,S,D}` **and** correction footnotes present (`A#`,`B#`,`M#`,`N#`,`L#`/`K#`, or ZIP4 added) | `corrected` (2) | +| `dpv_match_code === "Y"`, no correction footnotes, components match, not vacant/no-stat | `verified` (1) | + +> Order matters: evaluate `undeliverable` → `flagged` → secondary cases → `ambiguous` → `corrected` → `verified`. Encode as ordered guard clauses (CLAUDE.md "early returns over nesting"). Footnote parsing → a named helper `parseUsFootnotes(s): Set`; the footnote-class lists become named constants (CLAUDE.md "name magic numbers"). + +**International** (`verification_status` + `address_precision` + `changes`): + +| Condition | → type | +| ----------------------------------------------------------------------------------------------------------- | ------------------------- | +| `verification_status === "None"` ∥ `address_precision === "None"` | `undeliverable` (7) | +| `verification_status === "Ambiguous"` | `ambiguous` (6) | +| `changes.sub_building === "Unrecognized"` | `secondaryNotMatched` (4) | +| `verification_status === "Partial"` **and** `address_precision === "Premise"` **and** `sub_building` absent | `missingSecondary` (3) | +| `verification_status === "Verified"` **and** any `changes.* ∈ {Verified-SmallChange, Added}` | `corrected` (2) | +| `verification_status === "Verified"` **and** precision reached country max (Q10) | `verified` (1) | + +> **Type 5 (`flagged`) cannot fire internationally** — it is USPS-specific (§7 note, result-types.jsx:104). The intl branch never emits it. + +> **Q10 — per-country `max_address_precision` (gates R4 / RS Epic 4).** Type 1 means _reached the country's max precision_, **not** a hard `DeliveryPoint`. `classify` compares `address_precision` against `max_address_precision` from the **same response** rather than a hard-coded `"DeliveryPoint"`. This keeps good addresses in lower-coverage countries out of Types 3/7. Engineering resolution: read `max_address_precision` off each response; no static per-country table needed. If a response omits it, fall back to a precision-rank table (`None < ... < DeliveryPoint`) and treat `>= Premise` as verified-for-country, logging the gap. **This is the R4 entry gate — confirm against live intl responses during the spike before locking.** + +### 5.5 Behavior dispatch + +`classify` produces `type`; the orchestrator looks up `config.verification.onResult[type]` (falling back to the §3.1 default) and dispatches: + +| Behavior | Effect | +| ------------------ | ---------------------------------------------------------------------------------------- | +| `silent` | apply corrected address to form (if any); no UI; fire `onVerified`. | +| `apply-and-notify` | apply + show non-blocking note of the diff ("Adjusted to …"); fire `onVerified`. | +| `prompt` | surface UI asking the user to confirm/supply (secondary / did-you-mean); await decision. | +| `apply-primary` | apply corrected primary, keep+flag the entered unit (type 4); don't drop it. | +| `warn` | non-blocking caution; allow submit; fire `onVerified`. | +| `block` | (Epic 3) gate submit via `onBeforeSubmit` resolving false. | +| `first-candidate` | (type 6) auto-pick candidate[0]; apply. | +| `ignore` | no-op beyond recording the result. | + +Type 8 (`error`) bypasses `onResult` and is governed by `failureMode`: **fail-open** → record, fire `onVerificationFailed`, allow submit, UI aria-only/silent (result-types.jsx:158); **fail-closed** → additionally block submit (Epic 3 semantics). + +### 5.6 Triggers, dedupe, staleness + +- **Wiring:** an orchestrator (in `SmartyAddress` or a small `VerificationOrchestrator`) attaches listeners per `config.verification.trigger`: + - `selection` → hook into the existing selection path (where `onAddressSelected` fires). + - `blur` → listener on `streetSelector` (and last address field) — this is the free-form/paste path (PRD §4 mode 2, §5 "Free-form"). + - `submit` → the await-able pre-submit hook (Epic 3, §6). + - `manual` → only `verify()`. +- **Minimal in-memory dedupe (PRD §6, RS Epic 1):** `VerificationService` caches the last verified `CurrentAddress` fingerprint (normalized string of the 6 fields + country). A trigger whose fingerprint matches the last in-flight/completed verify is a no-op. This kills the documented `["selection","blur"]` double-call (selection then the blur it causes). **No persistent cache, no cross-field dedupe** — metering is the customer's job (§3 non-goal). Fingerprint cache is per-instance, cleared on `destroy()`. +- **Staleness / re-verification (PRD §9 Q9):** after a successful verify, `CurrentAddress.verifiedAt` is set and the verified fingerprint stored. On any edit to a watched field (`input`/`change` on the address selectors), compare the new fingerprint to the verified one: + - **Resolution (recommend): invalidate, don't auto-re-fire.** Clear the ✓ / badge, drop `verifiedAt`, mark stale. Re-verification happens on the next configured trigger (blur/submit) — not on every keystroke. Rationale: silently re-firing on edit spends billable Street calls per keystroke with no dedupe protection (§3) and fights the user mid-edit. This is the safer default and a one-line switch (`verification.staleness?: "invalidate" | "revalidate"`, default `"invalidate"`) if product wants the other behavior. + +--- + +## 6. Submission Blocking (Epic 3 / RS, PRD §9 Q7) + +**Highest-risk area; isolated to its own release.** The plugin cannot reliably intercept native form submission across vanilla, React, Angular, Vue, and non-`
` checkouts by hijacking the `submit` event. Strategy: + +- **Primary: an await-able `onBeforeSubmit` the integrator calls.** Customers `await smartyAddress.verifyBeforeSubmit()` (or `await onBeforeSubmit hook`) in their own submit handler; it returns a boolean (`true` = ok to submit). This works identically in every framework because the integrator owns the gate. Documented as the supported blocking path. +- **Best-effort native interception (vanilla `` only):** when a real `` is present and `block` is configured, attach a capturing `submit` listener that `preventDefault()`s, runs verify, and re-submits on pass. Documented as best-effort; SPA/non-form hosts must use the await-able method. +- `block` behavior and `failureMode: "fail-closed"` both route through this gate. Before Epic 3, `validateConfig` rejects `block` (§3.1 guard). + +Cross-framework validation against the §11 host matrix is the Epic 3 exit criterion (RS Epic 3). + +--- + +## 7. Hook Contracts (PRD §9 Q2) + +All hooks are **async-capable** (return `void | Promise` or a decision) to support customer-side modal flows (Q2). The orchestrator `await`s them. + +| Hook | Fires when | Signature | Return semantics | +| ---------------------- | --------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `onVerified` | every completed verify (any type 1–7) | `(r: VerificationResult) => void \| Promise` | awaited; return ignored. | +| `onVerificationFailed` | type 8 / `VerificationError` | `(e: VerificationError) => void \| Promise` | awaited; return ignored. | +| `onCorrectionOffered` | types 2/3/4/6 when behavior is `prompt` | `(diff, r) => VerificationDecision \| void \| Promise<…>` | if it returns a `VerificationDecision`, that overrides the built-in UI (lets customers supply their own modal). `void` → built-in UI handles it. | +| `onBeforeSubmit` | `submit` trigger (Epic 3) | `(r \| null) => boolean \| Promise` | `false` blocks submission. | + +Existing autocomplete hooks (`onAddressSelected`, etc., interfaces.ts:86) are untouched. Verification hooks live on the same config object. + +--- + +## 8. UI Layer (`VerificationUiService`) + +Follows the `DropdownService` pattern: DOM creation in JS, an `aria-live` announcement region (DropdownService.ts:449 `announce()`), themed entirely via CSS variables. **No raw CSS values** — all values reference `var(--smartyAddress__…)` (CLAUDE.md). UI specifics are pending prototype review (§6, Q3/Q4) — this service is built so each surface is swappable without touching classification/dispatch. + +### 8.1 Surfaces (config `verification.ui`) + +| Surface | Release | Description | +| ----------------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | +| `none` | R1 | no visible UI; hooks/form-population only. | +| `aria-only` | R1 | screen-reader announcements via the `announce()` region; no visible element. (Type 8 always uses this regardless of `ui`.) | +| `badge` | R1 | small status cue near the field — ✓ (type 1/2), caution (3/4/5), warning (7). | +| `panel` | **R2** | full inline panel: shows diff ("Adjusted to …"), secondary prompt, caution text. | +| ambiguous chooser | **R2** | candidate picker; in verification-only mode a **lightweight fallback** (no dropdown infra) — Q4. | + +### 8.2 New CSS variables (PRD §12.3 deliverable, §14) + +Declared in the appropriate `assets/styles/` file per CLAUDE.md (defaults in `base.ts`, palette in `colors.ts`, spacing in `spacing.ts`, typography/positioning in `misc.ts`), consumed in `theme.ts`. Provisional set (finalize against prototype): + +- `colors.ts`: `--smartyAddress__verifyPositive`, `--smartyAddress__verifyWarning`, `--smartyAddress__verifyNegative`, `--smartyAddress__verifyBadgeBg`, `--smartyAddress__verifyBadgeText`. +- `spacing.ts`: `--smartyAddress__verifyBadgeGap`, `--smartyAddress__verifyPanelPadding`. +- `misc.ts`: `--smartyAddress__verifyBadgeFontSize`, `--smartyAddress__verifyBadgeRadius`, `--smartyAddress__verifyPanelShadow`, `--smartyAddress__verifyIconSize`. + +Tones map to the `tone` field already in result-types.jsx (`positive`/`warning`/`negative`). + +--- + +## 9. Provisional Defaults — Encoding (PRD §6) + +Every default below is a single field in a `defaultVerificationConfig` literal (or `constants.ts`), changeable in one line per §2: + +```ts +export const defaultVerificationConfig: Required> & {...} = { + enabled: true, // PROVISIONAL — gated at RS Epic 5 public ship + trigger: ["selection", "blur"], + ui: "badge", + failureMode: "fail-open", + fieldLevelHighlighting: false, // atomic + onResult: { /* §3.1 table */ }, + // correctionPrompt.style: TBD (Q3) — left unset until prototype +}; +``` + +> **`verification.enabled` billing risk (PRD §6, RS Epic 5 gate).** If verification shares the autocomplete embedded key, defaulting `enabled: true` means autocomplete-only customers start firing billable Street API calls on a version bump. **This decision is deliberately deferred to a one-line change confirmed with Product at the public ship (RS Epic 5), not locked in the API.** The code must make flipping this default a single-line edit — which the `defaultVerificationConfig` literal guarantees. + +--- + +## 10. File-by-File Change Map + +| File | Change | Release | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| `src/interfaces.ts` | Add `VerificationConfig`, `VerificationResult`, `VerificationError`, `AddressDiff`, `DeliverabilityCode`, `CurrentAddress`, `VerificationDecision`, enums; add `verification?` to `SmartyAddressConfig`; add `verificationService?`/`verificationUiService?` to overrides. | R1 | +| `src/services/BaseService.ts` | Add `verificationService?` + `verificationUiService?` to `ServiceDependencies`. | R1 | +| `src/services/VerificationService.ts` | **New.** Street calls, `classify`, dispatch, dedupe, staleness. | R1 (intl branch R4) | +| `src/services/VerificationUiService.ts` | **New.** badge/aria (R1), panel/chooser (R2), intl (R4). | R1+ | +| `src/services/http/streetTransport.ts` | **New.** Shared auth/fetch/error helper (§1.1). | R1 | +| `src/services/FormService.ts` | Add `populateFormWithCurrentAddress` (corrections round-trip, Q6). | R1 | +| `src/index.ts` | Register new services in `SmartyAddress.services`; instantiate + wire + `init`; add public `verify()` and `verifyBeforeSubmit()`; mode resolution (§2). | R1 (submit R3) | +| `src/utils/configNormalizer.ts` | De-skew: fold root autocomplete keys into `autocomplete.*` as aliases; normalize `verification` block. | R1 | +| `src/utils/appUtils.ts` (`validateConfig`) | neither-mode-on warning; reject unsupported behaviors per release (§3.1 guard). | R1 | +| `src/constants.ts` | `US_STREET_API_URL`, `INTERNATIONAL_STREET_API_URL`, `defaultVerificationConfig`, footnote-class constants. | R1 (intl url R4) | +| `assets/styles/{colors,spacing,misc,base}.ts` + `theme.ts` | New CSS variables (§8.2). | R1 (panel vars R2) | + +--- + +## 11. Test Strategy (PRD §11, §13, Q11–Q13) + +- **Unit:** `classify` is a pure function — exhaustive table tests for all 8 US rows and all intl rows (incl. the Q10 max-precision boundary and the "type 5 can't fire intl" guard). Dedupe fingerprinting; staleness invalidation; behavior dispatch per type. +- **Playwright matrix (CI, RS Epic 0 stands up the harness before any release):** the full space is `trigger × behavior × ui × 8 result-types × 5+ frameworks` — **too large to run exhaustively (Q13)**. Pick representative cells deliberately and **document the intentionally-untested combinations in the harness README — no silent coverage gaps.** Suggested anchor cells: + - vanilla autocomplete+verify, `badge`, types 1/2/3/7 (R1) + - vanilla verification-only (paste→blur), types 2/3/7 (R1, Q8) + - React controlled + Angular reactive + Vue v-model, `panel`, type 6 (R2) + - blocking submit across all frameworks (R3, Q7) + - international country-switch mid-flow, types 1/2/3 (R4) + - network/quota/auth error → fail-open vs fail-closed (R1, type 8) + - edit-after-verify staleness (R1, Q9) +- **Per-public-release manual run (PRD §13):** `npm run dev` against a real embedded key — deliverable, corrected, undeliverable, network error, international. Screenshots saved under `.playwright-mcp/` per new UI state. +- **Hosted vs local harness (Q12):** decide in Epic 0; default to local Playwright in CI, hosted only if a framework host (Shopify/Wix sandbox) can't run locally. + +--- + +## 12. Release / Epic Alignment (RS source) + +This ERD is built so the R1 architecture accommodates R2–R4 with no rework (PRD §10). Mapping of ERD sections to epics: + +| Epic / Release | ERD sections that land | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| **Epic 0** — Discovery & API lock | Resolve Q3/Q4 (prototype), Q10 (live intl responses, §5.4), Q1/Q2; confirm this ERD's type/config surfaces; stand up §11 harness. | +| **Epic 1 / R1** — Framework + US (defaults) | §1–§5 (US branch), §5.6 (dedupe + staleness), §7 hooks (minus `onBeforeSubmit`), §8 badge/aria, de-skew (§2 note), types 1–5,7(warn),8. | +| **Epic 2 / R2** — Panel + ambiguous | §8 panel + chooser, type 6, `onResult.ambiguous`. | +| **Epic 3 / R3** — Blocking + pre-submit | §6 fully, `block` override + `fail-closed`, `onBeforeSubmit`, framework matrix. | +| **Epic 4 / R4** — International | §5.3 ordering, §5.4 intl branch (Q10 gate), intl UI. | +| **Epic 5** — Hardening + public ship | full §11 matrix sweep; `verification.enabled` default + billing confirmed (§9); §12.7 housekeeping. | + +--- + +## 13. Open Questions — Engineering Disposition + +| Q | PRD area | Disposition in this ERD | +| ------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| Q1 | defaults | Deferred to Product (Epic 0); encoded as one-line defaults (§9). | +| Q2 | hook signatures | **Proposed** async-capable signatures (§7); confirm with Product. | +| Q3 | correction prompt style | `correctionPrompt.style` config left unset pending prototype (§3, §8). | +| Q4 | ambiguous chooser (verif-only) | Lightweight fallback in `VerificationUiService` (§8.1); design in Epic 0/2. | +| Q5 | de-skew config | **Resolved:** nested `autocomplete` block + root-key aliases, additive (§2). | +| Q6 | current-address abstraction | **Resolved:** `CurrentAddress` + adapters; `FormService` round-trip (§4.1). | +| Q7 | submission blocking | **Resolved (strategy):** await-able method primary; best-effort native for vanilla (§6); validate Epic 3. | +| Q8 | verif-only sequencing | **Resolved:** `fromFormFields` adapter, no `address_id` anchor; blur/submit/manual (§5.6). | +| Q9 | staleness | **Resolved (recommend):** invalidate-on-edit, re-verify on next trigger; `staleness` switch for the alternative (§5.6). | +| Q10 | per-country precision | **Resolved (approach):** compare `address_precision` to per-response `max_address_precision`; gates R4, confirm vs live responses (§5.4). | +| Q11–13 | testing | Matrix + documented untested cells (§11). | + +--- + +## 14. Out of Scope (PRD §3) + +No built-in caching/dedupe beyond the minimal in-flight fingerprint (§5.6); no cost guardrails; no fresh-API rewrite (additive only); UX/default _finalization_ (owned by prototype + Product, not this ERD). diff --git a/PRD-address-verification.md b/PRD-address-verification.md new file mode 100644 index 0000000..0feac3c --- /dev/null +++ b/PRD-address-verification.md @@ -0,0 +1,257 @@ +# PRD: Address Verification for smarty-address + +**Status:** Draft — designs in progress +**Owner:** TBD (Product) · Engineering · Design · QA +**Source:** Scoping plan "Add Address Verification to smarty-address Plugin" + +> **Design note:** The UX treatments in this document (correction prompts, status +> badges, panels, ambiguous chooser) are **in-progress**. They will be decided +> against working interactive prototypes (Phase B, step 2), not finalized here. +> Anything marked _provisional_ or _TBD_ is expected to change after prototype review. + +--- + +## 1. Summary + +The plugin today wraps Smarty's **autocomplete** APIs (US Autocomplete Pro + +International Autocomplete v2). It helps users _find_ an address and fills form +fields, but never confirms deliverability or returns DPV codes, standardized +components, or corrections. + +This project adds **address verification** (Smarty's US Street API + International +Street API) so customers can confirm a final address before submission. The +end-state covers both US and international; releases may be staggered, but neither +is optional in the long run. + +## 2. Goals + +- Let customers verify a final address and surface deliverability, standardized + components, and corrections before form submission. +- Support verification **independently** of autocomplete (verification can run + with no dropdown and no suggestions). +- Ship an architecture that accommodates the full feature target (US + + international, all triggers, all behaviors, all UI surfaces) without rework, + even though early releases ship a subset. +- Keep configuration defaults changeable as a one-line change so final default + decisions can be deferred until there is a working build to test against. + +## 3. Non-Goals + +- Built-in caching, dedupe, or cost guardrails in v1 — metering is the customer's + responsibility. +- A blanket fresh-API rewrite. Breaking changes are allowed only where justified + (usage is low); changes are additive wherever possible. +- Finalizing UX treatments in this document — those are decided via prototype. + +## 4. Operating Modes + +The plugin must support three modes, selected by which products the customer +enables: + +1. **Autocomplete only** — today's behavior. +2. **Verification only** — new. No dropdown, no suggestions; verify free-form or + pasted addresses on blur/submit/manual. +3. **Both** — new. Autocomplete to find, then verification to confirm. + +If neither `autocomplete.enabled` nor `verification.enabled` is `true`, the plugin +warns and effectively no-ops at construction time. + +## 5. Decisions (locked) + +| Area | Decision | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Product scope | US + International; phased across releases, both required for "done". | +| Independence | Verification must work standalone; triggers and defaults adapt. | +| Trigger | Configurable: on selection, on submit/blur, and manual `verify()`. | +| Behavior on result | Silent enrichment, inline correction prompt, blocking submission, and hook-only all supported. | +| UX surface | Configurable per instance: status badge, ARIA-only, full inline panel, or none. | +| Free-form typed addresses | In scope — submit/blur trigger must handle them. | +| Metering / cost | Customer's responsibility; no built-in caching/dedupe in v1. | +| Failure mode | Fail open by default; configurable to fail closed. | +| Backwards compat | Additive where possible; breaking changes allowed where justified. Verification default won't be blocked by preserving exact current behavior. | +| Service boundary | New `VerificationService extends BaseService`, separate from `ApiService` (sharing only auth/transport at most). | +| Result storage | `VerificationResult` is its own top-level structure, not nested in `AutocompleteSuggestion`. | +| International ordering | Detail fetch first, then verification, so verify has full components. | +| Mockups | Working interactive code prototypes, not static design mockups. | + +## 6. Provisional Defaults + +These are **provisional** and must be a one-line change to revise after testing +against a working build. + +| Config | Default | Rationale / Risk | +| ------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `autocomplete.enabled` | `true` | Matches existing behavior. | +| `verification.enabled` | `true` _(provisional)_ | Part of the value prop. **Risk:** if verification shares the autocomplete embedded key, defaulting on means autocomplete-only customers begin firing billable Street API calls on a version bump. Decision deferred. | +| `verification.trigger` | `["selection", "blur"]` | **Note:** with no dedupe in v1, a blur right after a selection fires a second call. Revisit minimal in-memory dedupe before release. | +| `verification.behavior` | Result-type dependent (see taxonomy) | Not a single global setting; each type overridable. | +| `verification.ui` | Confidence cue on success (small ✓), escalating for problem results | UI specifics pending prototype. | +| `verification.failureMode` | `"fail-open"` | Don't block users when Smarty is unreachable. | +| `verification.fieldLevelHighlighting` | `false` (atomic) | Field-level highlighting is fragile across diverse forms; treat address as a unit until proven. | +| `verification.correctionPrompt.style` | **TBD** | Needs visual review (prototype) before decision. | + +## 7. Result Taxonomy & Behavior + +Behavior is keyed to the _kind_ of result Smarty returns, not a single global +switch. Each type has a sensible default that customers can override per type. +US uses `dpv_match_code` + `footnotes`; International uses +`analysis.verification_status` + `address_precision` + per-component `changes`. +Behaviors and UI are shared; only the input signal differs. + +> **Canonical encoding.** This taxonomy is also encoded as data in +> `design/verification/app/result-types.jsx` (its self-declared single source of +> truth) and transcribed into classification logic in ERD §3.1 / §5.4. Treat +> `result-types.jsx` as canonical when the three drift; update it first. + +| # | Type | US signal | International signal | Default behavior | Default UI _(provisional)_ | +| --- | ------------------------ | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------- | +| 1 | Verified, unchanged | `dpv_match_code: Y`, no correction footnotes, components match, `dpv_vacant: N`, `dpv_no_stat: N` | `verification_status: Verified`, precision at country max, all `changes = Verified-NoChange` | Accept silently | Small ✓ | +| 2 | Verified, corrected | Deliverable (Y/S/D) but `footnotes` show standardization (`A#`, `B#`, `M#`, `N#`, `L#`/`K#`; +ZIP4) | `Verified` with `changes` = `Verified-SmallChange`/`Added` | Apply correction **+ show what changed** (never silent swap) | Inline "Adjusted to …" + ✓ | +| 3 | Missing secondary | `dpv_match_code: D` (footnote `N1`) | `Partial`, `address_precision: Premise`, `sub_building` absent | Prompt for apt/unit/suite | Inline secondary prompt | +| 4 | Secondary not recognized | `dpv_match_code: S` | `changes.sub_building: Unrecognized` | Apply primary, flag the unit (don't drop it) | Inline "couldn't verify unit X" | +| 5 | Deliverable but flagged | `dpv_match_code: Y` with `dpv_vacant: Y`/`dpv_no_stat: Y` (or footnote `R7`) | _No equivalent — USPS-specific; cannot fire internationally_ | Warn, fail open | Inline caution, non-blocking | +| 6 | Ambiguous | Multiple candidates | `verification_status: Ambiguous` | Prompt user to choose | Chooser (lightweight fallback in verification-only mode) | +| 7 | Undeliverable / invalid | Zero candidates or `dpv_match_code: N` | `verification_status: None` and/or `address_precision: None` | Warn, fail open (allow submit) | Inline warning, non-blocking | +| 8 | Error | Network / auth / quota failure | Same | Governed by `failureMode` (fail-open default) | ARIA-only, silent | + +> **International "verified" is relative to the country.** `address_precision` +> is capped per country by `max_address_precision`; some countries can never +> reach `DeliveryPoint`. Type 1 must mean _reached the country's max precision_, +> not a hard `DeliveryPoint` check — otherwise good addresses in lower-coverage +> countries get mis-classified as Type 3 or Type 7. (See Open Question Q10.) + +**Per-type override shape:** finalized in **ERD §3 (config schema) and §3.1 +(per-type behavior defaults)**. The override keys and allowed values there are the +canonical contract. + +**Open design tensions (resolve via prototype):** + +- _Type 2 correction transparency_ — silent swap vs. inline note vs. did-you-mean prompt (see Q3). +- _Type 6 ambiguous in verification-only mode_ — needs a lightweight chooser/prompt fallback (see Q4). +- _Type 7 undeliverable_ — the global `failureMode` is fail-open, but some checkout + customers will want undeliverable specifically to **block**. Reinforces the need + for per-type overrides. + +## 8. Architecture + +Specced in full in **`ERD-address-verification.md` §1–§10** (the engineering spike +output this section originally sketched): service boundary (§1), config schema +(§3), type system incl. the "current address" abstraction (§4), `VerificationService` +control flow (§5), submission blocking (§6), hook contracts (§7), UI layer + CSS +variables (§8), and the file-by-file change map (§10). + +## 9. Open Questions (must resolve before locking the API) + +**Product** + +1. Confirm the recommended defaults with product before locking the API. +2. Exact hook signatures for `onVerified`, `onVerificationFailed`, + `onCorrectionOffered`; async-vs-sync return semantics (likely async to support + customer-side modal flows). + +**UX** 3. Correction prompt style (Type 2): inline "Adjusted to …" note vs. did-you-mean +prompt vs. silent swap — decided against prototypes. 4. Ambiguous chooser (Type 6) in verification-only mode — design the lightweight +fallback when no dropdown is running. + +**Engineering** _(Q5–Q9 resolved in the ERD; dispositions summarized in ERD §13.)_ 5. De-skew autocomplete-first config keys / README; add verification-neutral keys +alongside existing ones (keep old as aliases). Additive only. +**Resolved — ERD §2 + §13:** nested `autocomplete` block + root-key aliases. 6. "Current address" abstraction shared across autocomplete, free-form, and prior +verification; affects hook signatures and FormService coupling. +**Resolved — ERD §4.1:** `CurrentAddress` + source adapters. 7. Submission blocking across frameworks (vanilla, React, Angular, Vue, non-``); +likely an await-able pre-submit method rather than form interception. +**Resolved (strategy) — ERD §6:** await-able method primary, best-effort native +for vanilla ``; cross-framework validation is the Epic 3 exit criterion. 8. Verification-only call sequencing — when it fires without an `address_id` anchor. +**Resolved — ERD §5.6:** `fromFormFields` adapter; blur/submit/manual triggers. 9. Re-verification / staleness when the user edits a field after a successful +verify: invalidate (clear ✓, require re-verify) vs. silently re-trigger. +**Resolved (recommend) — ERD §5.6:** invalidate-on-edit, re-verify on next +trigger; `staleness` switch for the alternative. 10. Per-country "verified" threshold given `max_address_precision`; how Type 3/4/7 +boundaries shift below `DeliveryPoint`. Gates the international release. +**Approach in ERD §5.4** (compare precision to per-response `max_address_precision`); +**still open** — confirm against live international responses before R4. + +**Testing** 11. Expand the reference-scenario list (see §11) with product/customer input. 12. Hosted vs. local Playwright harness; rock-solid coverage likely needs CI-driven +Playwright across the matrix. 13. Config-matrix testability: trigger × behavior × UI × 8 result types × 5+ +frameworks is too large to cover exhaustively. Pick representative cells +deliberately and document untested combinations — no silent coverage gaps. + +## 10. Phased Releases + +Phasing is sequencing only; the R1 architecture must already accommodate R2–R4 +without rework. Each release must be **releasable on its own** — architecturally +complete and functionally self-contained, so any release _could_ ship publicly. + +The **current plan** is a single public release at R4, so public-release +housekeeping (changelog, version bump, docs team — §12.7) is done once, at that +point, not per release. This is a sequencing choice, not a constraint: if the plan +changes to ship an earlier release publicly, that release's housekeeping is done at +that time. Per-release internal test coverage applies throughout regardless (§13). +A pre-build discovery phase precedes R1 — prototypes, the engineering spike, and +API lock (§12.1–.5). + +- **Release 1 — Framework + US verification.** All triggers; `silent`, `hook-only`, + `apply-and-notify`/`prompt` behaviors; `aria-only` + `badge` UI. Covers + Types 1, 2, 3, 4, and 5. +- **Release 2 — `panel` UI + Type 6 (ambiguous chooser)**, including the + verification-only fallback. +- **Release 3 — `block` behavior + the await-able pre-submit method.** Highest-risk + release — cross-framework submission interception (Q7); sequenced on its own. +- **Release 4 — International verification.** Gated on **Q10** (per-country + `max_address_precision`). R4 completes the feature target; under the current + single-public-release plan it is the last increment before going public. The + final comprehensive regression sweep across the §13 matrix and the §12.7 + housekeeping may be executed either as part of R4 or as a separate post-R4 + hardening step — see the companion release-structure doc (Epic 5), which sequences + them on their own so a Q10 slip can't stall the release checklist. + +## 11. Test Scenarios (starting list — expand before locking) + +Vanilla HTML (autocomplete + verification); vanilla verification-only (paste + +submit); WooCommerce checkout (multi-field, country switcher); Shopify checkout +extension (sandboxed iframe); WordPress with multiple instances; React SPA +(controlled inputs); Angular reactive forms (FormGroup); Vue 3 (v-model); +multi-step wizard (verify on step transition); single-field combined address; +secondary unit selection then verify; international with country switching +mid-flow; network failure mid-verification; quota/auth error; user edits form +after a successful verification (the test for Q9). + +## 12. Phase B Workflow + +1. **PRD finalization (Product)** — confirm defaults; resolve Q1, Q2. +2. **Working prototypes (Eng + Design)** — interactive prototypes of problem + states (Types 2–7) against the dev server, autocomplete-present and + verification-only. Type 2 shows all three correction treatments side by side + (Q3); include the Q4 ambiguous-chooser fallback. **These are the mockups.** +3. **Engineering spike** — resolve Q6–Q10 against prototypes. Output: an + implementation plan superseding the scoping plan, with exact config keys, + types, hook contracts, the "current address" abstraction, submission-blocking + strategy, staleness behavior, per-country threshold, and CSS variable list. +4. **Test scenario expansion (QA + Eng)** — expand §11; build a Playwright + harness per scenario; commit to CI before any verification release. +5. **Customer scenario walkthroughs** — dry-run the config against every §11 + scenario before production code; adjust the API for awkward fits. +6. **Phased build & release** per §10. +7. **Per-public-release housekeeping** — done for each _actual_ public release + (currently a single one at R4; §10): README updates; changelog in the + `smarty/changelog` repo at `plugins/smarty-address-js.md`; version bump in both + `package.json` and `src/constants.ts` `APP_VERSION`. + +## 13. Verification & Acceptance + +- Unit tests per service + CI Playwright matrix across §11 scenarios (Q11–Q13). +- Per release: end-to-end manual run via `npm run dev` against a real embedded + key — deliverable, corrected, undeliverable, network error, international. +- Screenshots saved under `.playwright-mcp/` for each new UI state. + +## 14. Critical Files (background reading) + +- `src/services/ApiService.ts` — auth, request building, error handling; + international detail flow (`fetchInternationalAddressDetail`). +- `src/services/FormService.ts` — field population; corrections round-trip here. +- `src/services/DropdownService.ts` — `aria-live` `announce()` region and + `handleSelectDropdownItem`; new UI should follow this style. +- `src/services/BaseService.ts` — pattern the new `VerificationService` must follow. +- `src/interfaces.ts` — `SmartyAddressConfig`, `ApiConfig`, + `AutocompleteSuggestion`; new verification types added here. +- `src/index.ts` — service registration and `SmartyAddress.services` exports. +- `assets/styles/colors.ts`, `misc.ts`, `theme.ts` — new CSS values must be + variables in the appropriate file. diff --git a/acceptance/README.md b/acceptance/README.md new file mode 100644 index 0000000..06a4e44 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,93 @@ +# Address Verification — Acceptance Test Harness + +Playwright harness standing up the verification acceptance matrix (RS Epic 0, +PRD §12.4 / §13, Open Questions **Q11–Q13**). Specs drive the **built IIFE +bundle** (`dist/smarty-address.iife.js`) in a real Chromium browser with an +in-page `fetch` mock returning canned Street API payloads, then assert the +resulting DOM / submit decision. + +## Running + +```bash +npm run test:acceptance # builds the bundle, then runs Playwright +# or, against an existing build: +npx playwright test +``` + +First-time setup downloads the browser: `npx playwright install chromium`. + +## The matrix (Q13) + +The full space is **trigger × behavior × ui × 8 result-types × 5+ frameworks** — +far too large to run exhaustively (PRD §9 Q13). We pick representative anchor +cells deliberately and **document the intentionally-untested combinations +below** so there are no silent coverage gaps. + +### Covered cells (this harness) + +| # | Scenario | Trigger | UI | Result types | Notes | +|---|---|---|---|---|---| +| 1 | Vanilla verification-only | `manual` | `badge` | 1, 2, 7 | US Street; correction round-trips to the form | +| 2 | Blocking submission (Epic 3) | `manual` (`verifyBeforeSubmit`) | — | 7 (`block`) | undeliverable→block gates the submit | +| 3 | Native `` interception (Epic 3) | `submit` | `badge` | 2, 7 (`block`) | real trusted click: pass → deferred re-submit; block → submission held | +| 4 | Ambiguous chooser (Epic 2) | `manual` | `panel` | 6 | chooser → pick candidate → form filled | +| 5 | Edit-after-verify staleness (Q9) | `manual` | `badge` | 1 | trusted edit clears the ✓ | +| 6 | Service error fail-open (Type 8) | `manual` | `badge`→aria | 8 | aria-only; submit allowed | +| 7 | International (Epic 4) | `manual` | `badge` | 1 | International Street API, GBR | +| 8 | Country switch mid-flow (Epic 4) | `manual` | `badge` | 1 | `countrySelector` flip re-routes US API → International API (strict per-endpoint mock) | + +The in-page fetch mock has **no fallback**: a request to an unmocked endpoint +fails the test, so endpoint-routing bugs cannot pass silently. + +Unit-level coverage (Jest) complements this with the **full** classification +tables (all 8 US rows + all international rows incl. the Q10 max-precision +boundary), dedupe fingerprinting, behavior dispatch per type, and config +de-skew / validation. See `src/services/Verification*.test.ts`, +`src/services/verificationEpic{2,3,4}.test.ts`, `src/utils/*.test.ts`. + +### Intentionally untested here (documented gaps — Q13) + +These combinations are **not** exercised by the automated browser harness yet. +They are deliberate omissions, not oversights: + +- **Framework hosts** — React controlled inputs, Angular reactive forms (FormGroup), + Vue 3 `v-model`, Shopify checkout extension (sandboxed iframe), WooCommerce / + WordPress multi-instance. The harness validates the framework-agnostic core in + vanilla DOM (incl. native `` interception against a real trusted click); + **per-framework validation has not been performed yet** — it remains the open + Epic 3 exit criterion and must be recorded here when done. The supported + blocking path (`await verifyBeforeSubmit()`) is framework-independent by + construction. +- **`selection` + `blur` triggers in-browser** — covered at the unit level + (selection wiring, dedupe of the selection→blur double-call incl. merged + street+unit fields, single-field blur); the acceptance specs use the + deterministic `manual` trigger to avoid coupling to the autocomplete dropdown. + **Autocomplete-present verification (a live dropdown selection feeding + verify) is not exercised in-browser at all.** +- **Types 3 (missing secondary), 4 (secondary not matched), and 5 (flagged) + in-browser** — unit-covered only (classification + dispatch + prompt copy); + no acceptance cell renders their UI in a real browser. +- **`failureMode: "fail-closed"` and quota/auth (4xx) errors in-browser** — + the acceptance harness exercises only the network-500 fail-open path; the + fail-closed gate and error-kind mapping are unit-covered. +- **`apply-and-notify` vs `prompt` vs `silent` per type** — the dispatch matrix + is exhaustively covered in unit tests; the harness spot-checks the default + behaviors only. +- **International types 2/3/6/7 in-browser** — unit-covered only; the + acceptance intl cells are Type 1 (static GBR + country switch). +- **PRD §11 scenarios not yet automated anywhere** — multi-step wizard (verify + on step transition), secondary-unit selection then verify, WooCommerce / + Shopify / WordPress hosts. Single-field combined address is unit-covered + (blur trigger) but has no acceptance cell. +- **Live API responses** — all Street responses here are mocked. The per-release + manual run (PRD §13) exercises a real embedded key (deliverable, corrected, + undeliverable, network error, international) and is the source of truth for + **Q10** (per-country `max_address_precision`) before R4. +- **`fieldLevelHighlighting`** — atomic-only in v1 (PRD §6); per-field highlight + is out of scope until proven. + +## Hosted vs local (Q12) + +Default: **local** Playwright/Chromium in CI (this harness). Hosted runners are +reserved for framework hosts that can't run locally (e.g. a Shopify/Wix sandbox), +to be added when those framework cells are automated. diff --git a/acceptance/verification.spec.ts b/acceptance/verification.spec.ts new file mode 100644 index 0000000..84c3cd7 --- /dev/null +++ b/acceptance/verification.spec.ts @@ -0,0 +1,392 @@ +import { test, expect, Page } from "@playwright/test"; +import path from "path"; + +// Acceptance harness (RS Epic 0). Each test drives the built IIFE bundle in a +// real browser with an in-page fetch mock returning canned Street API payloads, +// then asserts the resulting DOM / decision. Representative matrix cells and the +// intentionally-untested combinations are documented in acceptance/README.md. + +const BUNDLE = path.join(process.cwd(), "dist", "smarty-address.iife.js"); + +const US_FORM = ` + + + + `; + +type MockResponse = { match: string; body: unknown; ok?: boolean; status?: number }; + +async function loadPlugin( + page: Page, + html: string, + responses: MockResponse[], + config: Record, +): Promise { + await page.setContent(`${html}`); + await page.addScriptTag({ path: BUNDLE }); + await page.evaluate( + async ({ responses, config }) => { + const win = window as unknown as { + fetch: unknown; + SmartyAddress: { create: (c: unknown) => Promise }; + __sa: unknown; + }; + win.fetch = async (url: unknown) => { + const target = String(url); + const match = (responses as MockResponse[]).find((r) => target.includes(r.match)); + // No fallback: a request to an unmocked endpoint must fail the test, + // otherwise a routing bug (e.g. intl address hitting the US API) + // would silently receive the canned payload and pass. + if (!match) throw new Error(`Unmocked fetch in acceptance test: ${target}`); + return { + ok: match.ok ?? true, + status: match.status ?? 200, + json: async () => match.body, + } as Response; + }; + win.__sa = await win.SmartyAddress.create(config); + }, + { responses, config }, + ); +} + +const verify = (page: Page) => + page.evaluate(async () => { + const result = await ( + window as unknown as { __sa: { verify: () => Promise } } + ).__sa.verify(); + return result as { type: string } | null; + }); + +const verifyBeforeSubmit = (page: Page) => + page.evaluate(() => + ( + window as unknown as { __sa: { verifyBeforeSubmit: () => Promise } } + ).__sa.verifyBeforeSubmit(), + ); + +const usConfig = (verification: Record) => ({ + embeddedKey: "test-key", + streetSelector: "#street", + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + autocomplete: { enabled: false }, + verification: { trigger: ["manual"], ...verification }, +}); + +const usCandidate = ( + analysis: Record, + components: Record, + line = "1600 Pennsylvania Ave NW", +) => ({ + delivery_line_1: line, + components: { + city_name: "Washington", + state_abbreviation: "DC", + zipcode: "20500", + ...components, + }, + analysis, +}); + +test.describe("US verification — verification-only, badge", () => { + test("Type 1: verified (entered already standardized) renders a positive badge", async ({ + page, + }) => { + // Entered address matches the candidate exactly → no diff → Type 1. + const matchedForm = ` + + + + `; + await loadPlugin( + page, + matchedForm, + [ + { + match: "us-street", + body: [ + { + delivery_line_1: "3214 N University Ave", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84604" }, + analysis: { dpv_match_code: "Y", footnotes: "" }, + }, + ], + }, + ], + usConfig({ ui: "badge" }), + ); + const result = await verify(page); + expect(result?.type).toBe("verified"); + await expect(page.locator(".smartyAddress__verifyBadge_positive")).toHaveText("Verified"); + }); + + test("Type 2: corrected applies the standardized address + shows Adjusted", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [ + { + match: "us-street", + body: [usCandidate({ dpv_match_code: "Y", footnotes: "A#N#" }, { plus4_code: "0003" })], + }, + ], + usConfig({ ui: "badge" }), + ); + await verify(page); + await expect(page.locator(".smartyAddress__verifyBadge")).toHaveText("Adjusted"); + await expect(page.locator("#zip")).toHaveValue("20500-0003"); + await expect(page.locator("#street")).toHaveValue("1600 Pennsylvania Ave NW"); + }); + + test("Type 7: undeliverable warns but does not block submit (fail-open)", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [{ match: "us-street", body: [usCandidate({ dpv_match_code: "N", footnotes: "" }, {})] }], + usConfig({ ui: "badge" }), + ); + const result = await verify(page); + expect(result?.type).toBe("undeliverable"); + await expect(page.locator(".smartyAddress__verifyBadge_negative")).toHaveText("Undeliverable"); + expect(await verifyBeforeSubmit(page)).toBe(true); + }); +}); + +test.describe("Blocking submission (Epic 3)", () => { + test("undeliverable→block blocks the submit gate", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [{ match: "us-street", body: [usCandidate({ dpv_match_code: "N", footnotes: "" }, {})] }], + usConfig({ onResult: { undeliverable: "block" } }), + ); + expect(await verifyBeforeSubmit(page)).toBe(false); + }); + + const NATIVE_FORM = `${US_FORM}`; + + // Counts submit events the plugin let through. Attached after plugin init so + // it runs behind the plugin's interception handler; preventDefault keeps the + // browser from navigating. + const trackPassedSubmits = (page: Page) => + page.evaluate(() => { + const win = window as unknown as { __passed: number }; + win.__passed = 0; + document.querySelector("#form")!.addEventListener("submit", (event) => { + if (!event.defaultPrevented) win.__passed++; + event.preventDefault(); + }); + }); + + test("native
interception: verified address is re-submitted", async ({ page }) => { + await loadPlugin( + page, + NATIVE_FORM, + [ + { + match: "us-street", + body: [usCandidate({ dpv_match_code: "Y", footnotes: "A#" }, { plus4_code: "0003" })], + }, + ], + usConfig({ trigger: ["submit"], ui: "badge" }), + ); + await trackPassedSubmits(page); + await page.click("#go"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __passed: number }).__passed)) + .toBe(1); + }); + + test("native interception: block override holds the submission", async ({ page }) => { + await loadPlugin( + page, + NATIVE_FORM, + [{ match: "us-street", body: [usCandidate({ dpv_match_code: "N", footnotes: "" }, {})] }], + usConfig({ trigger: ["submit"], onResult: { undeliverable: "block" }, ui: "badge" }), + ); + await trackPassedSubmits(page); + await page.click("#go"); + await expect(page.locator(".smartyAddress__verifyBadge_negative")).toHaveText("Undeliverable"); + expect(await page.evaluate(() => (window as unknown as { __passed: number }).__passed)).toBe(0); + }); +}); + +test.describe("Ambiguous chooser (Epic 2)", () => { + test("Type 6 renders a chooser; picking a candidate fills the form", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [ + { + match: "us-street", + body: [ + usCandidate( + { dpv_match_code: "Y", footnotes: "" }, + { plus4_code: "4402" }, + "120 W Center St", + ), + usCandidate( + { dpv_match_code: "Y", footnotes: "" }, + { plus4_code: "3108" }, + "120 E Center St", + ), + ], + }, + ], + usConfig({ ui: "panel" }), + ); + const result = await verify(page); + expect(result?.type).toBe("ambiguous"); + const options = page.locator(".smartyAddress__verifyChooserOption"); + await expect(options).toHaveCount(2); + await options.nth(1).click(); + await expect(page.locator("#street")).toHaveValue("120 E Center St"); + await expect(page.locator(".smartyAddress__verifyChooser")).toHaveCount(0); + }); +}); + +test.describe("Staleness (Q9)", () => { + test("editing a field after a successful verify clears the badge", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [{ match: "us-street", body: [usCandidate({ dpv_match_code: "Y", footnotes: "" }, {})] }], + usConfig({ ui: "badge" }), + ); + await verify(page); + await expect(page.locator(".smartyAddress__verifyBadge")).toHaveCount(1); + await page.locator("#street").fill("123 Different St"); + await expect(page.locator(".smartyAddress__verifyBadge")).toHaveCount(0); + }); +}); + +test.describe("Error handling (Type 8)", () => { + test("service error is aria-only and fail-open allows submit", async ({ page }) => { + await loadPlugin( + page, + US_FORM, + [{ match: "us-street", body: {}, ok: false, status: 500 }], + usConfig({ ui: "badge" }), + ); + const result = await verify(page); + expect(result?.type).toBe("error"); + await expect(page.locator(".smartyAddress__verifyBadge")).toHaveCount(0); + await expect(page.locator(".smartyAddress__verifyAnnouncer")).toContainText( + "temporarily unavailable", + ); + expect(await verifyBeforeSubmit(page)).toBe(true); + }); +}); + +test.describe("International verification (Epic 4)", () => { + const INTL_FORM = ` + + + `; + + test("Type 1 international renders a verified badge", async ({ page }) => { + await loadPlugin( + page, + INTL_FORM, + [ + { + match: "international-street", + body: [ + { + address1: "221B Baker St", + components: { locality: "London", postal_code: "NW1 6XE", country_iso3: "GBR" }, + analysis: { + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }, + }, + ], + }, + ], + { + embeddedKey: "test-key", + streetSelector: "#street", + localitySelector: "#city", + postalCodeSelector: "#zip", + country: "GBR", + autocomplete: { enabled: false }, + verification: { trigger: ["manual"], ui: "badge" }, + }, + ); + const result = await verify(page); + expect(result?.type).toBe("verified"); + await expect(page.locator(".smartyAddress__verifyBadge_positive")).toHaveText("Verified"); + }); + + test("country switch mid-flow re-routes from the US API to the international API", async ({ + page, + }) => { + const SWITCHING_FORM = ` + + + + + `; + await loadPlugin( + page, + SWITCHING_FORM, + [ + { + match: "us-street", + body: [ + { + delivery_line_1: "3214 N University Ave", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84604" }, + analysis: { dpv_match_code: "Y", footnotes: "" }, + }, + ], + }, + { + match: "international-street", + body: [ + { + address1: "221B Baker St", + components: { locality: "London", postal_code: "NW1 6XE", country_iso3: "GBR" }, + analysis: { + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }, + }, + ], + }, + ], + { + embeddedKey: "test-key", + streetSelector: "#street", + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + countrySelector: "#country", + autocomplete: { enabled: false }, + verification: { trigger: ["manual"], ui: "badge" }, + }, + ); + const usResult = (await verify(page)) as { type: string; source: string } | null; + expect(usResult?.type).toBe("verified"); + expect(usResult?.source).toBe("us"); + + await page.selectOption("#country", "GBR"); + await page.locator("#street").fill("221B Baker St"); + await page.locator("#city").fill("London"); + await page.locator("#state").fill(""); + await page.locator("#zip").fill("NW1 6XE"); + + const intlResult = (await verify(page)) as { type: string; source: string } | null; + expect(intlResult?.type).toBe("verified"); + expect(intlResult?.source).toBe("international"); + }); +}); diff --git a/assets/styles/base.ts b/assets/styles/base.ts index cac2268..6503d09 100644 --- a/assets/styles/base.ts +++ b/assets/styles/base.ts @@ -25,4 +25,39 @@ export const baseStyles = { "--smartyAddress__chevronRotation": "rotate(0deg)", "--smartyAddress__chevronRotationExpanded": "rotate(180deg)", }, + + // Fallback values for every verification variable. Verify surfaces sit + // outside the dropdown wrapper, so they carry this class plus the configured + // theme classes directly; theme blocks (colors.ts) override the palette vars + // because baseStyles is spread first in defineStyles. + ".smartyAddress__verify_default": { + "--smartyAddress__verifyBadgeDisplay": "inline-flex", + "--smartyAddress__verifyBadgeAlignItems": "center", + "--smartyAddress__verifyBadgeGap": "5px", + "--smartyAddress__verifyBadgePadding": "2px 8px", + "--smartyAddress__verifyBadgeFontSize": ".8em", + "--smartyAddress__verifyBadgeFontWeight": "600", + "--smartyAddress__verifyBadgeRadius": "4px", + "--smartyAddress__verifyBadgeBg": "transparent", + "--smartyAddress__verifyBadgeText": "#49505b", + + "--smartyAddress__verifyPositive": "#15803d", + "--smartyAddress__verifyWarning": "#b45309", + "--smartyAddress__verifyNegative": "#b91c1c", + + "--smartyAddress__verifyPanelDisplay": "block", + "--smartyAddress__verifyPanelPadding": "12px", + "--smartyAddress__verifyPanelGap": "8px", + "--smartyAddress__verifyPanelRadius": "6px", + "--smartyAddress__verifyPanelFontSize": ".9em", + "--smartyAddress__verifyPanelBg": "#fcfcfc", + "--smartyAddress__verifyPanelText": "#000", + "--smartyAddress__verifyPanelBorder": "1px solid #ccc", + "--smartyAddress__verifyPanelShadow": + "0 12px 24px 0 rgba(4, 34, 75, 0.10), 0 20px 40px 0 rgba(21, 27, 35, 0.06)", + + "--smartyAddress__verifyChooserOptionDisplay": "block", + "--smartyAddress__verifyChooserOptionWidth": "100%", + "--smartyAddress__verifyChooserOptionTextAlign": "left", + }, }; diff --git a/assets/styles/colors.ts b/assets/styles/colors.ts index 42b0e0c..48a20f7 100644 --- a/assets/styles/colors.ts +++ b/assets/styles/colors.ts @@ -17,6 +17,15 @@ export const colorStyles = { "--smartyAddress__largeShadow1": "0 12px 24px 0 rgba(4, 34, 75, 0.10)", "--smartyAddress__largeShadow2": "0 20px 40px 0 rgba(21, 27, 35, 0.06)", + + "--smartyAddress__verifyPositive": "#4ade80", + "--smartyAddress__verifyWarning": "#fbbf24", + "--smartyAddress__verifyNegative": "#f87171", + "--smartyAddress__verifyBadgeBg": "transparent", + "--smartyAddress__verifyBadgeText": "#e6e9ed", + "--smartyAddress__verifyPanelBg": "#111", + "--smartyAddress__verifyPanelText": "#fff", + "--smartyAddress__verifyPanelBorder": "1px solid #666", }, ".smartyAddress__color_light": { "--smartyAddress__textBasePrimaryColor": "#000", @@ -36,5 +45,14 @@ export const colorStyles = { "--smartyAddress__largeShadow1": "0 12px 24px 0 rgba(4, 34, 75, 0.10)", "--smartyAddress__largeShadow2": "0 20px 40px 0 rgba(21, 27, 35, 0.06)", + + "--smartyAddress__verifyPositive": "#15803d", + "--smartyAddress__verifyWarning": "#b45309", + "--smartyAddress__verifyNegative": "#b91c1c", + "--smartyAddress__verifyBadgeBg": "transparent", + "--smartyAddress__verifyBadgeText": "#49505b", + "--smartyAddress__verifyPanelBg": "#fcfcfc", + "--smartyAddress__verifyPanelText": "#000", + "--smartyAddress__verifyPanelBorder": "1px solid #ccc", }, }; diff --git a/assets/styles/theme.ts b/assets/styles/theme.ts index fb9dd76..3c3e7a6 100644 --- a/assets/styles/theme.ts +++ b/assets/styles/theme.ts @@ -129,4 +129,56 @@ export const themeStyles = { ".smartyAddress__smartyLogoLight": { display: "var(--smartyAddress__logoLightDisplay)", }, + + ".smartyAddress__verifyBadge": { + display: "var(--smartyAddress__verifyBadgeDisplay)", + "align-items": "var(--smartyAddress__verifyBadgeAlignItems)", + gap: "var(--smartyAddress__verifyBadgeGap)", + "font-size": "var(--smartyAddress__verifyBadgeFontSize)", + "font-weight": "var(--smartyAddress__verifyBadgeFontWeight)", + padding: "var(--smartyAddress__verifyBadgePadding)", + "border-radius": "var(--smartyAddress__verifyBadgeRadius)", + "background-color": "var(--smartyAddress__verifyBadgeBg)", + color: "var(--smartyAddress__verifyBadgeText)", + }, + + ".smartyAddress__verifyBadge_positive": { + color: "var(--smartyAddress__verifyPositive)", + }, + + ".smartyAddress__verifyBadge_warning": { + color: "var(--smartyAddress__verifyWarning)", + }, + + ".smartyAddress__verifyBadge_negative": { + color: "var(--smartyAddress__verifyNegative)", + }, + + ".smartyAddress__verifyPanel": { + display: "var(--smartyAddress__verifyPanelDisplay)", + padding: "var(--smartyAddress__verifyPanelPadding)", + "border-radius": "var(--smartyAddress__verifyPanelRadius)", + "font-size": "var(--smartyAddress__verifyPanelFontSize)", + "background-color": "var(--smartyAddress__verifyPanelBg)", + color: "var(--smartyAddress__verifyPanelText)", + border: "var(--smartyAddress__verifyPanelBorder)", + "box-shadow": "var(--smartyAddress__verifyPanelShadow)", + }, + + ".smartyAddress__verifyPanelMessage": { + "margin-bottom": "var(--smartyAddress__verifyPanelGap)", + }, + + ".smartyAddress__verifyChooserOption": { + display: "var(--smartyAddress__verifyChooserOptionDisplay)", + width: "var(--smartyAddress__verifyChooserOptionWidth)", + "text-align": "var(--smartyAddress__verifyChooserOptionTextAlign)", + cursor: "var(--smartyAddress__cursorStyle)", + padding: "var(--smartyAddress__verifyBadgePadding)", + "margin-top": "var(--smartyAddress__verifyPanelGap)", + "border-radius": "var(--smartyAddress__verifyBadgeRadius)", + "background-color": "var(--smartyAddress__verifyPanelBg)", + color: "var(--smartyAddress__verifyPanelText)", + border: "var(--smartyAddress__verifyPanelBorder)", + }, }; diff --git a/design/verification/Generic Result Surfacing.html b/design/verification/Generic Result Surfacing.html new file mode 100644 index 0000000..5b4aeb7 --- /dev/null +++ b/design/verification/Generic Result Surfacing.html @@ -0,0 +1,36 @@ + + + + + +Generic Result Surfacing — portable across any host + + + + + +
+ + + + + + + + + + + + + + diff --git a/design/verification/README.md b/design/verification/README.md new file mode 100644 index 0000000..926d1cf --- /dev/null +++ b/design/verification/README.md @@ -0,0 +1,25 @@ +# CODING AGENTS: READ THIS FIRST + +This is a **handoff bundle** from Claude Design (claude.ai/design). + +A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real. + +## What you should do — IMPORTANT + +**Read the chat transcripts first.** There are 4 chat transcript(s) in `js-plugin/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives. + +**Read `js-plugin/project/Generic Result Surfacing.html` in full.** The user had this file open when they triggered the handoff, so it's almost certainly the primary design they want built. Read it top to bottom — don't skim. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing. + +**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing. + +## About the design files + +The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit. + +**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't. + +## Bundle contents + +- `js-plugin/README.md` — this file +- `js-plugin/chats/` — conversation transcripts (read these!) +- `js-plugin/project/` — the `JS Plugin` project files (HTML prototypes, assets, components) diff --git a/design/verification/app/generic-main.jsx b/design/verification/app/generic-main.jsx new file mode 100644 index 0000000..e72f25f --- /dev/null +++ b/design/verification/app/generic-main.jsx @@ -0,0 +1,412 @@ +// ===================================================================== +// ASSEMBLY — proves the generic result UI is portable. +// +// Section 1 · "One component, any host" — the SAME rendered +// inside five unrelated host designs. Each board is pinned to a different +// representative result type, so one glance shows both the spread of +// result types AND that the component inherits each host's look. +// +// Section 2 · "The portable pattern set" — the five surfacing patterns +// (pill → note → popover → panel → modal) in one neutral host, ordered +// by escalating force, each with its own result-type stepper. +// ===================================================================== + +// Neutral host for the pattern-comparison section — quiet so the pattern, +// not the chrome, is what differs board to board. +const neutralTheme = { + root: { + fontFamily: 'Inter, system-ui, sans-serif', color: '#1f2328', background: '#ffffff', + '--sa-accent': '#2563eb', '--sa-accent-fg': '#ffffff', '--sa-radius': '8px', '--sa-field-bg': '#ffffff', + }, + fieldGap: '0.75em', + titleStyle: { fontSize: '1.2em', fontWeight: 600 }, + label: { gap: '0.3em', style: { fontSize: '0.78em', fontWeight: 500, color: '#656d76' } }, + field: { style: { + font: 'inherit', fontSize: '0.95em', padding: '0.6em 0.8em', color: 'inherit', + background: '#fff', border: '1px solid #d0d5dd', borderRadius: '8px', outline: 'none', boxSizing: 'border-box', + } }, + cta: { + background: '#2563eb', color: '#fff', border: 'none', borderRadius: '8px', + padding: '0.7em 1em', fontSize: '0.95em', fontWeight: 600, + }, +}; +function NeutralHost({ values, changed, children }) { + return ( +
+ Shipping address + + {children} + +
+ ); +} + +// Compact result-type switcher. Neutral dark strip that sits above each host +// card (outside it, so it never inherits the host theme). Uses the status +// hue per type for the active chip. +function MiniStepper({ n, setN }) { + const t = RESULT_TYPES[n]; + const hue = { positive: '#1e9e57', info: '#2a7de1', warning: '#d2891b', negative: '#d6485a' }[t.tone]; + return ( +
+
+ Smarty result type + {n + 1} / 8 +
+
+ {RESULT_TYPES.map((r, i) => { + const on = i === n; + const h = { positive: '#1e9e57', info: '#2a7de1', warning: '#d2891b', negative: '#d6485a' }[r.tone]; + return ( + + ); + })} +
+
+ + {t.n}. {t.name} +
+
+
+ Do + {t.behavior} +
+
+ UI + {t.ui} +
+
+
+ ); +} + +function BoardFrame({ children }) { + return ( +
+ {children} +
+ ); +} + +// ---- Section 1 board: one host, the inline-note pattern ------------- +function HostBoard({ host, initial, branded }) { + const [n, setN] = React.useState(initial || 0); + const Host = host.Comp; + const type = RESULT_TYPES[n]; + const anchor = anchorForType(type); + return ( + + +
+ + + + + +
+
+ ); +} + +const PATTERN_RENDER = { pill: ResultPill, note: ResultNote, popover: ResultPopover, panel: ResultPanel }; + +// ---- Section 2 board: one pattern, neutral host --------------------- +function PatternBoard({ pattern, initial, branded }) { + const [n, setN] = React.useState(initial != null ? initial : 1); + const [open, setOpen] = React.useState(true); + const type = RESULT_TYPES[n]; + const isModal = pattern === 'modal'; + const Pattern = PATTERN_RENDER[pattern]; + return ( + + { setN(v); setOpen(true); }} /> +
+ + {!isModal && } + {isModal && !open && ( +
+ + gates the Continue button. +
+ )} +
+ {isModal && open && ( +
+ setOpen(false)} /> +
+ )} +
+
+ ); +} + +// ===================================================================== +// DECISION BOARDS — the two UX choices Epic 0 must lock against the +// prototypes (PRD §9 Q3 + Q4). Unlike the pattern boards above, each +// scenario here is PINNED (no stepper): the point is to compare treatments +// of a SINGLE result, not to step the taxonomy. Each card names the config +// value it maps to so the lock translates straight into the API. +// ===================================================================== + +const T2_CORRECTED = RESULT_TYPES.find((r) => r.key === 'corrected'); // Type 2 +const T6_AMBIGUOUS = RESULT_TYPES.find((r) => r.key === 'ambiguous'); // Type 6 + +// Frames one option: a dark header (name + the config value it maps to + +// optional "recommended" flag) above the live host card, with a tradeoff +// line. Mirrors MiniStepper's chrome so it sits cleanly beside the others. +function DecisionCard({ name, config, tradeoff, pick, children }) { + return ( + +
+
+ {name} + {pick && Recommended default} +
+ {config} +
{tradeoff}
+
+
+ {children} +
+
+ ); +} + +// ---- Q3 · Type 2 correction treatments ------------------------------ +// Treatment A — SILENT SWAP. The corrected value is applied with no signal +// beyond a generic check; the user never learns the address was changed. +// (PRD §7 rules this out for corrections — shown so the team sees why.) +function T2Silent() { + return ( +
+ + Verified +
+ ); +} + +// Treatment B — INLINE NOTE (apply-and-notify). Applies the fix so the user +// isn't blocked, then shows exactly what changed with an Undo. +function T2InlineNote() { + return ( +
+ +
+
Adjusted to the standard address
+
Updated to the official postal format.
+
+ +
+
+ ); +} + +// Treatment C — DID-YOU-MEAN prompt. Nothing is applied until the user picks +// between the standardized version and what they entered. +function T2DidYouMean() { + const [sel, setSel] = React.useState('rec'); + return ( +
+
+ +
+
Did you mean this address?
+
We found a standardized version — choose which to use.
+
+
+
+ setSel('rec')} tag="Recommended" tone="positive" addr={T2_CORRECTED.corrected} changed={T2_CORRECTED.changed} /> + setSel('entered')} tag="As you entered" addr={T2_CORRECTED.entered} /> +
Use selected address
+
+
+ ); +} + +// ---- Q4 · ambiguous chooser, autocomplete-present mode -------------- +// When a dropdown is already running, the ambiguous candidates re-populate +// it — the user picks from the same surface they were searching in. Mocked +// to mirror the plugin's existing chameleon dropdown (inherits host accent). +function DropdownChooserMock({ candidates }) { + const [hi, setHi] = React.useState(0); + return ( +
+
+
+ Did you mean? +
+ {candidates.map((c, i) => ( + + ))} +
+ +
+
+
+ ); +} + +// ===================================================================== +function App() { + const [annotate, setAnnotate] = React.useState(true); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +// Fixed meta control (sibling of the canvas, so canvas pan/zoom never eats +// its clicks). Toggles the anchor-annotation layer on the Section-1 boards. +function AnnotateToggle({ on, setOn }) { + return ( +
+ + + DOM anchor points + + +
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/design/verification/app/generic-panel-modal.jsx b/design/verification/app/generic-panel-modal.jsx new file mode 100644 index 0000000..3a4aecb --- /dev/null +++ b/design/verification/app/generic-panel-modal.jsx @@ -0,0 +1,266 @@ +// ===================================================================== +// GENERIC RESULT SURFACING — confirmation panel + modal patterns. +// Same contract as generic-result.jsx: inherit font/color, semantics in the +// icon + rule only, accent + radius inherited, tints via color-mix. +// Branches keyed to the PRD §7 result KEY (not tone heuristics). +// ===================================================================== + +// ===================================================================== +// PATTERN 4 — CONFIRMATION PANEL (highest clarity) +// The classic "you entered vs. recommended" chooser, rendered as an inline +// expansion of the form. Auditable; best where a wrong address is expensive. +// ===================================================================== +function ResultPanel({ type, branded }) { + const isError = type.key === 'error'; + const isAmbiguous = type.key === 'ambiguous'; + const isCorrection = type.key === 'corrected'; + const isFlagged = type.key === 'flagged'; + const isUndeliverable = type.key === 'undeliverable'; + const needsUnit = type.needsInput === 'secondary'; + + const [sel, setSel] = React.useState('rec'); + const [cand, setCand] = React.useState(0); + const [unit, setUnit] = React.useState(''); + + // Type 8 · ERROR — silent. + if (isError) return ; + + let heading, inner; + if (isAmbiguous) { + heading = 'More than one match'; + inner = ( + <> + {type.candidates.map((c, i) => ( + setCand(i)} tag={`Match ${i + 1}`} addr={c} /> + ))} +
Use selected address
+ + ); + } else if (isUndeliverable) { + heading = 'No match found'; + inner = ( + <> + +
+ Edit address + Use as entered +
+ + ); + } else if (isFlagged) { + heading = 'Deliverable, but flagged'; + inner = ( + <> + +
Use this address
+ + ); + } else if (needsUnit) { + heading = type.flagUnit ? 'Confirm the unit' : 'Add a unit number'; + inner = ( + <> + +
+ setUnit(e.target.value)} placeholder={type.flagUnit ? type.entered.secondary : 'Apt, suite, unit…'} + style={{ + flex: 1, minWidth: 0, font: 'inherit', fontSize: '0.95em', padding: '0.6em 0.7em', + borderRadius: 'var(--sa-radius, 6px)', color: 'inherit', + background: 'var(--sa-field-bg, color-mix(in srgb, currentColor 4%, transparent))', + border: '1px solid color-mix(in srgb, currentColor 24%, transparent)', outline: 'none', + }} /> + {type.flagUnit ? 'Recheck' : 'Confirm'} +
+ + ); + } else if (isCorrection) { + heading = 'Confirm your address'; + inner = ( + <> + setSel('rec')} tag="Recommended" tone="positive" + addr={type.corrected} changed={type.changed} /> + setSel('entered')} tag="As you entered" + addr={type.entered} /> +
Use selected address
+ + ); + } else { + heading = 'Address confirmed'; + inner = ( + <> + +
Use this address
+ + ); + } + + return ( +
+
+ +
+
+ {heading} + {branded && } +
+
{type.guidance}
+
+
+
+ {inner} +
+
+ ); +} + +function PanelAddr({ label, addr }) { + return ( +
+ {label} + +
+ ); +} + +function PanelChoice({ selected, onClick, tag, tone = 'positive', addr, changed }) { + return ( + + ); +} + +// ===================================================================== +// PATTERN 5 — MODAL PROMPT (most forceful) +// Verification gates "continue": a centered dialog interrupts to confirm a +// correction, collect a unit, choose among matches, or force acknowledgement +// of an unverified address. A silent error shows no dialog (fail-open) — +// represented here, in-prototype, by the aria-only strip. +// ===================================================================== +function ResultModal({ type, branded, onClose }) { + const isError = type.key === 'error'; + const isAmbiguous = type.key === 'ambiguous'; + const isCorrection = type.key === 'corrected'; + const isFlagged = type.key === 'flagged'; + const isUndeliverable = type.key === 'undeliverable'; + const needsUnit = type.needsInput === 'secondary'; + const [unit, setUnit] = React.useState(''); + const [cand, setCand] = React.useState(0); + + let title, sub, addrBlock, btns; + if (isError) { + title = 'No dialog shown'; + sub = 'Errors are silent — verification failed and submit proceeds (fail-open).'; + addrBlock = ; + btns = OK; + } else if (isAmbiguous) { + title = 'Which address did you mean?'; + sub = type.guidance; + addrBlock = ( +
+ {type.candidates.map((c, i) => ( + setCand(i)} tag={`Match ${i + 1}`} addr={c} /> + ))} +
+ ); + btns = Use selected address; + } else if (isCorrection) { + title = 'Confirm your address'; + sub = 'Updated to the official postal format.'; + addrBlock = ( +
+ + +
+ ); + btns = <>Use recommendedUse mine; + } else if (needsUnit) { + title = type.flagUnit ? 'Check the unit number' : 'A unit number is required'; + sub = type.guidance; + addrBlock = ( +
+ + setUnit(e.target.value)} placeholder={type.flagUnit ? type.entered.secondary : 'Apt, suite, unit…'} autoFocus + style={{ + width: '100%', boxSizing: 'border-box', font: 'inherit', fontSize: '0.95em', marginTop: '0.6em', + padding: '0.7em 0.8em', borderRadius: 'var(--sa-radius, 6px)', color: 'inherit', + background: 'var(--sa-field-bg, color-mix(in srgb, currentColor 4%, transparent))', + border: `1px solid ${GR_HUE[type.tone]}`, outline: 'none', + }} /> +
+ ); + btns = Confirm & continue; + } else if (isFlagged) { + title = 'Deliverable, but flagged'; + sub = type.guidance; + addrBlock = ; + btns = <>Continue anywayEdit; + } else if (isUndeliverable) { + title = 'We couldn’t verify this address'; + sub = type.guidance; + addrBlock = ; + btns = <>Edit addressSubmit anyway; + } else { + title = 'Address verified'; + sub = type.guidance; + addrBlock = ; + btns = Looks good; + } + + return ( +
+
e.stopPropagation()} style={{ + width: '100%', maxWidth: 360, borderRadius: 'calc(var(--sa-radius, 8px) * 1.6)', + background: 'var(--sa-field-bg, var(--sa-pop-bg, #fff))', + boxShadow: '0 24px 60px rgba(0,0,0,0.35)', padding: '1.25em', + }}> +
+ +
+
{title}
+
{sub}
+
+ +
+ {addrBlock} +
{btns}
+ {branded &&
} +
+
+ ); +} + +Object.assign(window, { ResultPanel, PanelAddr, PanelChoice, ResultModal }); diff --git a/design/verification/app/generic-result.jsx b/design/verification/app/generic-result.jsx new file mode 100644 index 0000000..0c1b914 --- /dev/null +++ b/design/verification/app/generic-result.jsx @@ -0,0 +1,513 @@ +// ===================================================================== +// GENERIC RESULT SURFACING — the portable, host-agnostic result UI. +// +// Design contract (mirrors how the plugin's chameleon dropdown already +// behaves: it reads the host input and themes itself rather than imposing +// Smarty's brand): +// +// 1. INHERIT, DON'T IMPOSE. Root sets `font: inherit` + `color: inherit`, +// so type face, size and text color always match the host form. +// 2. SEMANTICS LIVE IN THE ICON + ACCENT RULE, NEVER THE TEXT. The only +// asserted colors are status hues (green / amber / red) carried by the +// icon, a thin rule and a tint. Headings and body copy stay currentColor +// so they're legible on a white gov form OR a near-black dashboard. +// 3. TINTS ARE color-mix(... transparent). Backgrounds let the host surface +// show through — no hard-coded white/gray that breaks in dark mode. +// 4. ACTIONS INHERIT THE HOST ACCENT (--sa-accent), not Smarty blue. +// 5. RADIUS + DENSITY INHERIT (--sa-radius). A square gov field stays +// square; a pill-rounded SaaS field stays rounded. +// +// Everything below consumes only: inherited font/color, currentColor, +// --sa-accent, --sa-accent-fg, --sa-radius, --sa-field-bg. No design-system +// tokens, no brand hexes. That's what makes it drop into anything. +// ===================================================================== + +// Status hue per tone. Chosen mid-saturation so the icon reads on both a +// white surface and a near-black one. Everything else is derived from these +// via color-mix, so a host could override --sa-pos/--sa-warn/--sa-neg too. +const GR_HUE = { + positive: 'var(--sa-pos, #1e9e57)', + info: 'var(--sa-info, #2a7de1)', + warning: 'var(--sa-warn, #d2891b)', + negative: 'var(--sa-neg, #d6485a)', +}; +const GR_ICON = { positive: 'check_circle', info: 'info', warning: 'alert', negative: 'alert' }; + +// tint(): a translucent wash of the status hue. transparent second stop means +// the HOST surface shows through, so it works on light and dark alike. +function tint(tone, pct) { return `color-mix(in srgb, ${GR_HUE[tone]} ${pct}%, transparent)`; } + +// ===================================================================== +// ANNOTATION LAYER — a meta overlay (toggled globally) that shows WHERE the +// plugin would insert the note in the host DOM. Deliberately styled OUTSIDE +// the host palette (violet "inspector" accent + mono) so it reads as a +// developer overlay, not part of the form. +// AnnotateContext — global on/off (provided by App, driven by the toggle) +// AnchorContext — the resolved anchor for THIS board (provided per board) +// ===================================================================== +const ANNOT = '#7c3aed'; +const AnnotateContext = React.createContext(false); +const AnchorContext = React.createContext(null); + +// Maps a result type to the host field the note anchors to + the real plugin +// selector that locates it. Mirrors the insertion logic discussed: the +// note lands beside the field its result concerns. +function anchorForType(type) { + if (type.key === 'error') { + // Silent — nothing rendered; the aria-live region announces from the form root. + return { key: 'street', selectorName: 'liveRegion', place: 'afterend', note: 'silent · aria-live only' }; + } + if (type.key === 'ambiguous') { + // Chooser — sits beneath the street field where the match is unresolved. + return { key: 'street', selectorName: 'streetSelector', place: 'afterend', note: 'chooser' }; + } + if (type.key === 'undeliverable') { + // Undeliverable is fail-open — sits after the last field but never gates submit. + return { key: 'zip', selectorName: 'postalCodeSelector', place: 'afterend', note: 'non-blocking' }; + } + if (type.needsInput === 'secondary') { + // Unit required / unrecognized — anchored to the secondary field. + return { key: 'secondary', selectorName: 'secondarySelector', place: 'afterend' }; + } + // Corrections, flagged, plain verified — anchored to the street field. + return { key: 'street', selectorName: 'streetSelector', place: 'afterend' }; +} + +// Small violet pill that tags the anchored field. +function AnchorTag() { + return ( + + + note anchors here + + ); +} + +// Caption strip shown above the note when annotations are on — names the +// selector + insertAdjacentElement call the plugin would use. +function AnchorCaption({ anchor }) { + if (!anchor) return null; + return ( +
+ + {anchor.selectorName} · insertAdjacentElement('{anchor.place}') + {anchor.note ? — {anchor.note} : null} +
+ ); +} + +// ===================================================================== +// ATOMS +// ===================================================================== + +// Status glyph in a soft tinted disc. Disc + icon are the only saturated color. +function StatusGlyph({ tone, size = 34 }) { + return ( + + + + ); +} + +// Inherited-accent action button. Primary fills with the host accent; +// secondary is a hairline using currentColor. No brand color anywhere. +function GAction({ children, primary, danger, onClick, full }) { + const base = { + appearance: 'none', font: 'inherit', fontWeight: 600, fontSize: '0.92em', + lineHeight: 1, cursor: 'pointer', padding: '0.7em 1.05em', + borderRadius: 'var(--sa-radius, 6px)', transition: 'opacity .12s, background .12s', + width: full ? '100%' : 'auto', textAlign: 'center', whiteSpace: 'nowrap', + }; + if (primary) { + return ( + + ); + } + return ( + + ); +} + +// Address one-liner. Changed tokens are marked with the positive hue via +// underline + weight — NOT a highlight fill (yellow fills die on dark hosts). +function GDiff({ addr, changed = [], muted }) { + if (!addr) return null; + const tok = (k, txt) => { + const on = changed.includes(k); + return ( + {txt} + ); + }; + return ( + + {tok('street', addr.street)} + {addr.secondary ? <>, {tok('secondary', addr.secondary)} : null} + {', '}{tok('city', addr.city)}, {tok('state', addr.state)} {tok('zip', addr.zip)} + + ); +} + +// "USPS standardized" style label row used inside notes/panels. +function GFieldLabel({ children }) { + return ( +
{children}
+ ); +} + +// Optional, opt-in attribution. Off by default — many hosts won't want it. +function PoweredBy() { + return ( + + verified by smarty + + ); +} + +// Generic ARIA-only state (Type 8 · error). The user sees nothing; this dashed +// strip is a PROTOTYPE-ONLY rendering of what the aria-live region announces, +// plus a reminder that submit proceeds (fail-open). Host palette, no brand. +function GAriaOnly({ text }) { + return ( +
+ +
+
+ Nothing shown · aria-live="polite" +
+
“{text}”
+
Fail-open — submit proceeds.
+
+
+ ); +} + +// Generic candidate chooser (Type 6 · ambiguous). Works with no dropdown — +// the lightweight verification-only fallback (PRD Q4). Inherits host accent. +function GChooser({ candidates, sel, onSel }) { + return ( +
+ {candidates.map((c, i) => { + const on = sel === i; + return ( + + ); + })} +
+ ); +} + +// ===================================================================== +// PATTERN 1 — INLINE NOTE (the universal default) +// A block that sits directly beneath the field. Lowest assumptions about +// host layout; works in one-column and two-column forms alike. +// ===================================================================== +function ResultNote({ type, branded }) { + const isError = type.key === 'error'; + const isAmbiguous = type.key === 'ambiguous'; + const isCorrection = type.key === 'corrected'; + const isFlagged = type.key === 'flagged'; + const isUndeliverable = type.key === 'undeliverable'; + const needsUnit = type.needsInput === 'secondary'; + const [unit, setUnit] = React.useState(''); + const [sel, setSel] = React.useState(null); + const annotate = React.useContext(AnnotateContext); + const anchor = React.useContext(AnchorContext); + + // Type 8 · ERROR — silent. No visible note; aria-live announces. + if (isError) { + return ( + <> + {annotate && anchor && } + + + ); + } + + let heading, sub, body; + if (isUndeliverable) { + heading = 'We couldn’t verify this address'; + sub = type.guidance; + body = ( +
+ Edit address + Keep as entered +
+ ); + } else if (isAmbiguous) { + heading = 'More than one match'; + sub = type.guidance; + body = ( + <> + +
+ Use selected +
+ + ); + } else if (needsUnit) { + heading = type.flagUnit ? 'We couldn’t verify the unit' : 'Add a unit number'; + sub = type.guidance; + body = ( +
+ setUnit(e.target.value)} placeholder={type.flagUnit ? type.entered.secondary : 'Apt, suite, unit…'} + style={{ + flex: 1, minWidth: 0, font: 'inherit', fontSize: '0.95em', padding: '0.6em 0.7em', + borderRadius: 'var(--sa-radius, 6px)', color: 'inherit', + background: 'var(--sa-field-bg, color-mix(in srgb, currentColor 4%, transparent))', + border: '1px solid color-mix(in srgb, currentColor 24%, transparent)', outline: 'none', + }} /> + {type.flagUnit ? 'Recheck' : 'Confirm'} +
+ ); + } else if (isCorrection) { + heading = 'Adjusted to the standard address'; + sub = 'Updated to the official postal format.'; + body = ( +
+ Recommended + +
+ Use recommended + Keep mine +
+
+ ); + } else if (isFlagged) { + heading = 'Deliverable, but flagged'; + sub = type.guidance; + body = null; + } else { + heading = 'Address verified'; + sub = type.guidance; + body = null; + } + + return ( + <> + {annotate && anchor && } +
+ +
+
+ {heading} + {branded && } +
+ {sub &&
{sub}
} + {body} +
+
+ + ); +} + +// ===================================================================== +// PATTERN 2 — INLINE STATUS PILL (lowest friction) +// A tiny chip rendered next to / under the field. For corrections it shows +// the new value inline; only no-match escalates wording. +// ===================================================================== +function ResultPill({ type, branded }) { + const isError = type.key === 'error'; + const isAmbiguous = type.key === 'ambiguous'; + const isCorrection = type.key === 'corrected'; + const isUndeliverable = type.key === 'undeliverable'; + const isFlagged = type.key === 'flagged'; + const [sel, setSel] = React.useState(null); + + // Type 8 · ERROR — silent. + if (isError) return ; + + const label = { + verified: 'Verified', corrected: 'Adjusted', + 'missing-secondary': 'Unit required', 'bad-secondary': 'Check unit', + flagged: 'Deliverable · flagged', ambiguous: 'Multiple matches', + undeliverable: 'Undeliverable', + }[type.key] || type.badge; + + return ( +
+ + + {label} + + {isCorrection && ( +
+ + +
+ )} + {isAmbiguous && ( + <> + + Use selected + + )} + {isFlagged && Deliverable — this won’t block submit.} + {isUndeliverable && Check for typos, or submit as entered.} + {branded && } +
+ ); +} + +// ===================================================================== +// PATTERN 3 — ANCHORED POPOVER (floats from the field) +// For when there's no room below the field — it overlays. Same content as +// the note, with a little caret. Anchored by the host wrapper (position). +// ===================================================================== +function ResultPopover({ type, branded }) { + const isError = type.key === 'error'; + const isAmbiguous = type.key === 'ambiguous'; + const isCorrection = type.key === 'corrected'; + const isFlagged = type.key === 'flagged'; + const isUndeliverable = type.key === 'undeliverable'; + const needsUnit = type.needsInput === 'secondary'; + const [sel, setSel] = React.useState(null); + + // Type 8 · ERROR — silent, never floats. + if (isError) return ; + + let heading, sub; + if (isUndeliverable) { heading = 'No match found'; sub = 'Check for typos, or submit as entered.'; } + else if (isAmbiguous) { heading = 'Which address did you mean?'; sub = type.guidance; } + else if (needsUnit) { heading = type.flagUnit ? 'Check the unit' : 'Add a unit number'; sub = type.guidance; } + else if (isCorrection) { heading = 'Use the standardized address?'; sub = null; } + else if (isFlagged) { heading = 'Deliverable, but flagged'; sub = type.guidance; } + else { heading = 'Address verified'; sub = type.guidance; } + + return ( +
+ +
+
+ +
+
{heading}
+ {sub &&
{sub}
} + {isCorrection && ( +
+ +
+ )} + {isAmbiguous && } +
+ {isUndeliverable + ? <>EditKeep + : isAmbiguous + ? Use selected + : needsUnit + ? {type.flagUnit ? 'Recheck unit' : 'Add unit'} + : isCorrection + ? <>Use itKeep mine + : isFlagged + ? <>ContinueEdit + : Got it} +
+
+
+ {branded &&
} +
+
+ ); +} + +Object.assign(window, { + GR_HUE, GR_ICON, tint, StatusGlyph, GAction, GDiff, GFieldLabel, PoweredBy, + GAriaOnly, GChooser, + ResultNote, ResultPill, ResultPopover, + AnnotateContext, AnchorContext, anchorForType, AnchorTag, AnchorCaption, ANNOT, +}); diff --git a/design/verification/app/hosts.jsx b/design/verification/app/hosts.jsx new file mode 100644 index 0000000..3c3c5de --- /dev/null +++ b/design/verification/app/hosts.jsx @@ -0,0 +1,488 @@ +// ===================================================================== +// HOST ENVIRONMENTS — five deliberately different form designs the plugin +// might be dropped into. Each sets its OWN font, text color, surface, +// accent (--sa-accent / --sa-accent-fg), corner radius (--sa-radius) and +// field background (--sa-field-bg) on its root — AND its own field LAYOUT. +// +// The point: real address forms don't agree on structure. Some use +// "Address line 1 / 2", some "Street + Apt", some a single autocomplete +// search box, some a dense 2-column grid, some a numbered gov stack — and +// they wrap the address fields in unrelated fields (name, company, country, +// phone). The plugin's generic result UI has to slot into ALL of them, so +// these shells exercise that spread. Nothing Smarty-branded lives here. +// +// Layout is data: theme.layout is an array of ROWS; each row is an array of +// field descriptors. A descriptor keyed to an address field (street / +// secondary / city / state / zip) is bound to the live result values and +// picks up the "changed" highlight; anything marked `decorative` is just +// realistic surrounding chrome (name, company, country, phone). +// ===================================================================== + +const ADDR_KEYS = ['street', 'secondary', 'city', 'state', 'zip']; + +const US_STATES = ['AL','AK','AZ','AR','CA','CO','CT','DC','DE','FL','GA','HI','IA','ID','IL','IN','KS','KY','LA','MA','MD','ME','MI','MN','MO','MS','MT','NC','ND','NE','NH','NJ','NM','NV','NY','OH','OK','OR','PA','RI','SC','SD','TN','TX','UT','VA','VT','WA','WI','WV','WY']; +const COUNTRIES = ['United States', 'Canada', 'United Kingdom', 'Australia', 'Mexico']; + +// One field. Visual identity (border, radius, font, label treatment) comes +// from the theme; structure comes from the descriptor. +function HostField({ fld, values, theme, changed }) { + const l = theme.label; + const annotate = React.useContext(AnnotateContext); + const anchor = React.useContext(AnchorContext); + const isAddr = (ADDR_KEYS.includes(fld.k) || fld.combined) && !fld.decorative; + // A single combined field is the anchor target for ANY address-keyed result. + const isAnchor = annotate && anchor && isAddr && (anchor.key === fld.k || fld.combined); + const oneLine = (a) => `${a.street}${a.secondary ? ' ' + a.secondary : ''}, ${a.city}, ${a.state} ${a.zip}`; + const val = fld.combined ? oneLine(values) : (isAddr ? (values[fld.k] ?? '') : (fld.sample ?? '')); + const isChanged = isAddr && (fld.combined ? changed.length > 0 : changed.includes(fld.k)); + const base = { + ...theme.field.style, + borderColor: isChanged + ? 'color-mix(in srgb, var(--sa-accent) 55%, ' + theme.field.style.borderColor + ')' + : theme.field.style.borderColor, + ...(isAnchor ? { outline: `2px dashed ${ANNOT}`, outlineOffset: '2px' } : null), + }; + + let control; + if (fld.kind === 'select') { + control = ( + + ); + } else if (fld.kind === 'search') { + control = ( +
+ + +
+ ); + } else if (fld.kind === 'combined') { + control = ( +
+ + +
+ ); + } else { + control = ; + } + + return ( + + ); +} + +// Render a theme's layout (array of rows). Single-field rows stack full +// width; multi-field rows lay out side by side with the theme's gap. +function HostFields({ values, theme, changed = [] }) { + const layout = theme.layout || DEFAULT_LAYOUT; + return ( +
+ {layout.map((row, i) => + row.length === 1 ? ( + + ) : ( +
+ {row.map((fld, j) => )} +
+ ) + )} +
+ ); +} + +// The conventional fallback: Street / Apt / City-State-ZIP. Used by the +// neutral host so the pattern section stays about the pattern, not the form. +const DEFAULT_LAYOUT = [ + [{ k: 'street', label: 'Street address' }], + [{ k: 'secondary', label: 'Apt / suite', ph: 'Optional' }], + [ + { k: 'city', label: 'City', flex: '1.5 1 0' }, + { k: 'state', label: 'State', flex: '0.6 1 0' }, + { k: 'zip', label: 'ZIP', flex: '0.9 1 0' }, + ], +]; + +function HostCTA({ theme, label }) { + return ( + + ); +} + +// Title row used by most hosts. +function HostTitle({ children, sub, theme }) { + return ( +
+
{children}
+ {sub &&
{sub}
} +
+ ); +} + +// --------------------------------------------------------------------- +// 1 · BARE / LEGACY GOV — unstyled defaults. Serif, square fields, the +// classic browser look. LAYOUT: a numbered, fully-stacked form — every +// field full width, state as a native setFocused(true)} + onBlur={() => setFocused(false)} + style={{ + flex: 1, border: 'none', outline: 'none', background: 'transparent', + fontFamily: 'var(--font-body)', + fontSize: 16, lineHeight: '24px', + color: 'var(--color-text-primary)', + minWidth: 0, + }} + {...rest} + /> + + {(hint || error) && ( + + {error || hint} + + )} + + ); +} + +// ============================================================= +// TAG / CHIP — 360px radius, 12–13px label +// ============================================================= +function Tag({ tone = 'neutral', children, icon, closable, onClose, style = {} }) { + const toneMap = { + neutral: { bg: 'var(--color-surface-tertiary)', fg: 'var(--color-text-secondary)' }, + info: { bg: 'var(--color-surface-secondary-tinted)', fg: 'var(--color-text-on-lit-blu)' }, + positive: { bg: 'var(--color-surface-positive)', fg: 'var(--color-text-positive)' }, + warning: { bg: 'var(--color-surface-warning)', fg: 'var(--color-text-warning)' }, + negative: { bg: 'var(--color-surface-negative)', fg: 'var(--color-text-negative)' }, + dark: { bg: 'var(--gray-950)', fg: 'var(--gray-0)' }, + }[tone]; + return ( + + {icon && } + {children} + {closable && ( + + )} + + ); +} + +// ============================================================= +// ALERT — banner with semantic surface + icon + text +// ============================================================= +function Alert({ tone = 'info', title, children, icon, style = {} }) { + const toneMap = { + info: { bg: 'var(--color-surface-secondary-tinted)', fg: 'var(--color-text-on-lit-blu)', icon: 'info', iconColor: 'var(--blue-400)' }, + positive: { bg: 'var(--color-surface-positive)', fg: 'var(--color-text-positive)', icon: 'check_circle', iconColor: 'var(--green-500)' }, + warning: { bg: 'var(--color-surface-warning)', fg: 'var(--color-text-warning)', icon: 'alert', iconColor: 'var(--orange-500)' }, + negative: { bg: 'var(--color-surface-negative)', fg: 'var(--color-text-negative)', icon: 'alert', iconColor: 'var(--pink-500)' }, + }[tone]; + return ( +
+ +
+ {title &&
{title}
} +
{children}
+
+
+ ); +} + +// ============================================================= +// CARD — white surface with `--shadow-md`, 16–24px radius +// ============================================================= +function Card({ children, padding = 24, radius = 16, elevation = 'md', style = {}, ...rest }) { + return ( +
+ {children} +
+ ); +} + +// ============================================================= +// SMARTY LOGO (wordmark + dot-logo) +// ============================================================= +function SmartyLogo({ height = 28, color = 'var(--blue-400)' }) { + // recreated from the Figma wordmark — "smarty" set in a heavy rounded sans + // with the dot on the "i" tucked as a solid square. Color via currentColor. + return ( + + smarty + + ); +} + +function SmartyDot({ size = 40 }) { + // The Smarty "a" mark — a rounded square with a hole, sourced from the figma logo icon. + return ( + + + + ); +} + +// ============================================================= +// NAV — marketing-site top nav +// ============================================================= +function TopNav({ links, cta }) { + return ( + + ); +} + +// ============================================================= +// Expose globally for Babel script-file scope sharing +// ============================================================= +Object.assign(window, { + Icon, Button, Input, Tag, Alert, Card, SmartyLogo, SmartyDot, TopNav, +}); diff --git a/design/verification/lib/design-canvas.jsx b/design/verification/lib/design-canvas.jsx new file mode 100644 index 0000000..0eac177 --- /dev/null +++ b/design/verification/lib/design-canvas.jsx @@ -0,0 +1,974 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) + +/* BEGIN USAGE */ +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Exports (to window): DesignCanvas, DCSection, DCArtboard, DCPostIt. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// +// +// Artboards are static design frames, not scroll regions — never use +// height: 100% + overflow: auto/scroll on inner elements; size each artboard +// to fit its content (explicit pixel height, or let it grow). +/* END USAGE */ + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// Recursively unwrap React.Fragment so <>… grouping doesn't hide +// DCSection/DCArtboard children from the type-based walks below. +function dcFlatten(children) { + const out = []; + React.Children.forEach(children, (c) => { + if (c && c.type === React.Fragment) out.push(...dcFlatten(c.props.children)); + else out.push(c); + }); + return out; +} + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Fragments are flattened; wrapping in other + // elements still opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + dcFlatten(children).forEach((sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + dcFlatten(sec.props.children).forEach((ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // --dc-inv-zoom consumers (.dc-sectionhead's CSS zoom, each section's + // marginBottom) reflow on every scale change, vertically shifting the + // world layout — so a world point mathematically pinned under the cursor + // drifts as you zoom (content creeps up on zoom-in, down on zoom-out). + // Anchor the DOM element under the cursor instead: record its screen Y, + // apply the transform + --dc-inv-zoom, then cancel whatever vertical + // drift the reflow introduced so it stays put on screen. + let marker = null, markerY0 = 0; + if (k !== 1) { + const hit = document.elementFromPoint(cx, cy); + marker = hit && hit.closest ? hit.closest('[data-dc-slot],[data-dc-section]') : null; + if (marker) markerY0 = marker.getBoundingClientRect().top; + } + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + if (marker) { + // A pure zoom around (cx, cy) maps screen Y → cy + (Y - cy) * k. Any + // departure after the --dc-inv-zoom reflow is the layout drift. + const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k); + if (Math.abs(drift) > 0.1) { t.y -= drift; apply(); } + } + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(dcFlatten(children)); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/epic-0-discovery-status.md b/epic-0-discovery-status.md new file mode 100644 index 0000000..dfe46df --- /dev/null +++ b/epic-0-discovery-status.md @@ -0,0 +1,44 @@ +# Epic 0 — Discovery & API Lock: Status + +**Companion to:** `release-structure-address-verification.md` (Epic 0), +`PRD-address-verification.md` (§9, §12), `ERD-address-verification.md`. + +Epic 0 de-risks the build before R1. Its primary output — the engineering spike +/ implementation plan with exact config keys, types, hook contracts, the +"current address" abstraction, submission-blocking strategy, staleness behavior, +per-country threshold, and CSS variable list — **is the ERD**, which is the +locked API contract that Epics 1–4 were built against. + +## Exit criteria + +| Exit criterion | Status | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **API locked** | ⚠️ **Locked with caveats.** The type/config/hook surfaces are fixed (`interfaces.ts`, `defaultVerificationConfig`) and exercised by Epics 1–4 — but Q2 (hook signatures) and Q3 (correction-prompt style) are implemented as proposals, not Product/UX-confirmed, and the prototype review the release structure calls for has not happened. Changes are expected to be additive. | +| **Q1–Q9 resolved** | ⚠️ **Q5–Q9 (engineering) resolved** in the ERD and implemented. **Q1–Q3 are NOT resolved** — deferred to Product (Q1, Q2) and UX prototype review (Q3); encoded as one-line-changeable defaults so the lock didn't require locking them. Q4 resolved (chooser fallback built). | +| **Representative test matrix chosen + documented (Q11–Q13)** | ✅ `acceptance/README.md` — anchor cells + intentionally-untested combinations. Gap list maintained as cells land; the ERD §11 "international country-switch" anchor cell is now automated. | +| **CI test harness in place** | ✅ Playwright harness (`acceptance/`, `playwright.config.ts`, `npm run test:acceptance`) wired into CI; runnable in real Chromium. | + +## Open-question dispositions + +| Q | Area | Disposition | +| --- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Q1 | Recommended defaults | **Deferred to Product.** Encoded as one-line-changeable `defaultVerificationConfig` (constants.ts §9). No code lock. | +| Q2 | Hook signatures (`onVerified`, `onVerificationFailed`, `onCorrectionOffered`) | **Proposed + implemented** as async-capable signatures (ERD §7, `interfaces.ts`). Confirm with Product; signatures are additive. | +| Q3 | Correction-prompt style (Type 2) | **Deferred to UX prototype.** `verification.correctionPrompt.style` config exists but is left unset pending prototype review (`design/verification/`). Default behavior is `apply-and-notify`. | +| Q4 | Ambiguous chooser in verification-only mode | **Resolved (lightweight fallback built).** `VerificationUiService.renderChooser` is a no-dropdown candidate picker (Epic 2). Visual polish pending prototype review. | +| Q5 | De-skew config keys | ✅ **Resolved + shipped:** nested `autocomplete` block + root-key aliases, additive (`configNormalizer`). | +| Q6 | "Current address" abstraction | ✅ **Resolved + shipped:** `CurrentAddress` + adapters; FormService round-trip. | +| Q7 | Submission blocking across frameworks | ✅ **Strategy resolved + shipped:** await-able `verifyBeforeSubmit()` primary; best-effort native `` interception. Cross-framework manual validation is the Epic 3 exit criterion (see matrix gaps). | +| Q8 | Verification-only sequencing | ✅ **Resolved + shipped:** `fromFormFields` adapter; blur/submit/manual triggers. | +| Q9 | Staleness / re-verification | ✅ **Resolved + shipped:** invalidate-on-edit (default), `staleness` switch for revalidate. | +| Q10 | Per-country `max_address_precision` | **Approach implemented; gates R4.** `classifyInternational` compares `address_precision` to the per-response `max_address_precision` (fallback: Premise minimum). **Still to confirm against live international responses** before public ship. | +| Q11 | Reference-scenario list | ⚠️ **Not yet expanded.** PRD §11 remains the starting list; expansion requires the product/customer input the PRD calls for. `acceptance/README.md` maps which §11 scenarios are automated vs. documented gaps. | +| Q12 | Hosted vs local Playwright | ✅ **Decided:** local Chromium in CI by default; hosted reserved for sandboxed framework hosts. | +| Q13 | Config-matrix testability | ✅ Representative cells chosen; untested combinations documented (no silent gaps). | + +## Deferred to Epic 5 (public ship) + +- **`verification.enabled` default + embedded-key billing behavior** — the single + highest-blast-radius decision. Deliberately a one-line change confirmed with + Product at the public ship (RS Epic 5), not locked here. The + `defaultVerificationConfig.enabled` field makes flipping it a one-line edit. diff --git a/jest.config.js b/jest.config.js index 8f14150..837ada4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,4 +15,6 @@ export default { }, setupFilesAfterEnv: ["/jest.setup.ts"], testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], + // Playwright acceptance specs run under @playwright/test, not jest. + testPathIgnorePatterns: ["/node_modules/", "/acceptance/"], }; diff --git a/package-lock.json b/package-lock.json index 050dcd1..19296b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "smarty-address", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smarty-address", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { + "@playwright/test": "^1.60.0", "@types/jest": "^30.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -1628,6 +1629,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -4936,6 +4953,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/package.json b/package.json index 80e8ed0..0917cf3 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "build": "npm run build:esm && npm run build:cdn", "build:esm": "tsc && cp -r assets dist/", "build:cdn": "vite build", - "test": "jest" + "test": "jest", + "test:acceptance": "npm run build && playwright test" }, "exports": { ".": { @@ -44,6 +45,7 @@ } }, "devDependencies": { + "@playwright/test": "^1.60.0", "@types/jest": "^30.0.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bace633 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Acceptance harness for address verification (RS Epic 0, PRD §12.4, §13). +// Specs drive the built IIFE bundle (dist/smarty-address.iife.js) in a real +// browser with an in-page fetch mock, exercising the representative matrix cells +// documented in acceptance/README.md. Run `npm run build` first. +export default defineConfig({ + testDir: "./acceptance", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/release-structure-address-verification.md b/release-structure-address-verification.md new file mode 100644 index 0000000..bae2ed8 --- /dev/null +++ b/release-structure-address-verification.md @@ -0,0 +1,179 @@ +# Address Verification — Release Structure + +**Status:** Proposed +**Companion to:** `PRD-address-verification.md` (source of truth; §-refs below point there) + +> **Increments are internal; only Epic 5 is public.** Each epic produces a demoable, +> architecturally-complete increment, but the single **public** release lands at +> Epic 5 (subject to change). "Usable on its own" (PRD §10) means +> _internally demoable and built without rework_ — **not** publicly shipped. +> Only Epic 5 carries public-release obligations (changelog, version bump, docs +> team). Every epic still carries its own internal test coverage. + +> **Epic → release map.** R-numbers track _feature scope_ (PRD §10), epics track +> _execution_. The mapping is 1:1 except at the ends: Epic 0 (discovery) → no release · +> Epics 1–4 → R1–R4 · Epic 5 (hardening + publish) → no new feature scope. There is +> no "R5" — Epic 5 ships no new feature, only the final regression and the once-only +> public-release housekeeping. + +--- + +## Epic 0 — Discovery & API Lock + +Pre-build de-risking. Locking the API is the **output** of this epic, not an +input — it depends on resolving the open questions against working prototypes +(PRD §12.2–.3, §9). + +- Finish interactive prototypes for problem states (Types 2–7), autocomplete-present + **and** verification-only; Type 2 shows all three correction treatments side by + side. Resolve **Q3** (correction-prompt style) and **Q4** (ambiguous-chooser + fallback) against the prototype review. _(PRD §12.2 — "these are the mockups")_ +- Confirm recommended defaults with Product; resolve **Q1, Q2**. _(Exception: the + `verification.enabled` default + embedded-key billing behavior stays a deferred + one-line decision, gated at Epic 5's public ship rather than locked here — PRD §6.)_ +- Engineering spike against the prototypes: resolve **Q5–Q9**. Output: implementation + plan with exact config keys, types, hook contracts, the "current address" + abstraction, submission-blocking strategy, staleness behavior, CSS variable list. + _(PRD §12.3)_ +- Customer scenario walkthroughs — dry-run the config against §11 scenarios; adjust + for awkward fits. _(PRD §12.5)_ +- **Lock the API.** +- Stand up the acceptance-test harness (Playwright) and commit to CI **before any + release**: expand the §11 scenario list (**Q11**), decide hosted-vs-local + (**Q12**), and **pick the representative matrix cells** (trigger × behavior × UI × + result type × framework), documenting which combinations are intentionally + untested — no silent coverage gaps (**Q13**). _(PRD §12.4)_ + +**Exit criteria:** API locked; **Q1–Q9** resolved; representative test matrix chosen +and documented (**Q11–Q13**); CI test harness in place. + +--- + +## Epic 1 — R1: Framework + US Verification (defaults only) + +The foundation plus the default happy path. _(PRD §10 R1 — Types 1–5; `silent`, +`hook-only`, `apply-and-notify`/`prompt` behaviors; `aria-only` + `badge` UI.)_ + +> **Scope note:** R1 also carries **Type 8** (error → `failureMode` dispatch) and +> **Type 7's _default_ behavior** (warn, fail-open, non-blocking), even though +> PRD §10 lists only "Types 1–5." Both are foundational, not deferrable: every +> `verify()` call can fail (Type 8), and a US flow must define _some_ behavior for +> an undeliverable address from day one (Type 7 warn). Epic 3 later adds only the +> `block` **override** on top of the Type 7 default built here — it does not +> introduce Type 7. + +- Build verification foundation: `VerificationService extends BaseService`, new + top-level types (`VerificationResult`, `VerificationError`, `AddressDiff`, + `DeliverabilityCode`), the "current address" abstraction. _(PRD §8)_ +- **De-skew the config surface (Q5 implementation):** add verification-neutral + config keys alongside the autocomplete-first keys, keeping the old keys as + aliases. Additive only. This lands here because every later epic builds on the + config surface; Epic 0 resolves the _plan_ (Q5), Epic 1 ships it. _(PRD §9 Q5)_ +- Build core UI elements: `badge` + `aria-only` surfaces. +- Write core business logic: result taxonomy Types 1–5, plus **Type 7 (warn, + non-blocking)** and **Type 8 (error)**; per-type behavior dispatch governed by + `failureMode`. +- **Trigger handling + minimal in-memory dedupe:** wire all triggers (selection, + blur, manual `verify()`) and add the minimal in-memory dedupe so the default + `["selection", "blur"]` doesn't fire a second billable call on the blur that + follows a selection. _(PRD §6 — "revisit minimal in-memory dedupe before release")_ +- Implement staleness / re-verification behavior — invalidate or re-trigger when the + user edits a field after a successful verify (per the **Q9** resolution). _(PRD §9 Q9)_ +- Wire up US functionality (defaults only), **in both autocomplete-present and + verification-only modes** — verification-only (no dropdown, no `address_id` + anchor; verify free-form/pasted addresses on blur/submit/manual) is a primary + operating mode (PRD §4 mode 2) and ships in R1, per the **Q8** resolution. Only + the `panel` UI and the Type 6 chooser's verification-only fallback are deferred + to Epic 2. _(PRD §4, §9 Q8)_ +- Internal test coverage for the above. + +**Exit criteria:** US default flow demoable end-to-end in **both** autocomplete-present +and verification-only modes; Types 1–5, 7 (warn), and 8 covered; dedupe prevents the +selection→blur double-call; staleness behavior verified; internal tests green. + +--- + +## Epic 2 — R2: Panel UI + Ambiguous Chooser + +Non-default UI surfaces. _(PRD §10 R2 — `panel` UI + Type 6, incl. the +verification-only fallback, Q4.)_ + +- Build `panel` UI surface. +- Add Type 6 (ambiguous) business logic + the chooser, including the + verification-only (no-dropdown) fallback. +- Wire up. +- Internal test coverage. + +**Exit criteria:** Panel + ambiguous chooser demoable in both autocomplete-present +and verification-only modes. + +--- + +## Epic 3 — R3: Blocking Submission + Pre-Submit Hook + +**Highest-risk epic.** Cross-framework submission interception. Isolated on purpose. +_(PRD §10 R3, §9 Q7.)_ + +- Build the await-able pre-submit lifecycle hook. +- Add the `block` **override** on top of the Type 7 default (warn) built in Epic 1 — + undeliverable → block, configurable per type. Type 7 itself is not introduced here. +- Validate across vanilla, React, Angular, Vue, and non-`` hosts (Q7). +- Wire up. +- Internal test coverage across the framework matrix. + +**Exit criteria:** Blocking submission verified across all target frameworks. + +--- + +## Epic 4 — R4: International Verification + +The last feature increment. Completes the full feature target (US + international). +_(PRD §10 R4.)_ + +**Gating entry criterion:** resolve **Q10** (per-country `max_address_precision`; +how Type 3/4/7 boundaries shift below `DeliveryPoint`) — explicitly flagged as +gating the international release. + +- Update UI elements to support international. +- Add international business logic (detail fetch → verify ordering per §5). +- Wire up international functionality. +- Internal test coverage for the international cells (first-time testing, not + regression — these rows have never run before this epic). + +**Exit criteria:** International flow demoable end-to-end; international result types +covered; internal tests green. + +--- + +## Epic 5 — Final Hardening + Public Release + +The single public-release increment. Carries the cross-cutting regression and the +once-only housekeeping bundle, decoupled from international feature work so a Q10 +slip in Epic 4 can't silently swallow the release checklist. _(PRD §10, §12.7, §13.)_ + +**Entry criterion:** Epics 1–4 complete (full feature target built and individually +demoable). + +**Public-ship gate — confirm the `verification.enabled` default + billing behavior.** +This is the single highest-blast-radius decision and it only bites at the public +ship, so it gates _here_, not in Epic 0. If verification shares the autocomplete +embedded key, defaulting `verification.enabled: true` means autocomplete-only +customers begin firing billable Street API calls on a version bump (PRD §6). Confirm +the final default and the embedded-key billing story with Product before publish — +this is deliberately deferred as a one-line change (PRD §6) rather than locked +against an unbuilt system in Epic 0. + +- **Final regression + integration hardening** — full-matrix sweep across the + representative cells (trigger × behavior × UI × 8 result types × frameworks), + run together for the first time. _(PRD §13, Q13.)_ Regression for the + US/domestic cells already exercised in Epics 1–3; the international cells from + Epic 4 get their dedicated coverage there, so this sweep validates them **in + combination** with the rest of the matrix rather than in isolation. +- Final code review across the full feature surface. +- **Public-release housekeeping (once):** README double-check; changelog in + `smarty/changelog` at `plugins/smarty-address-js.md`; version bump in both + `package.json` and `src/constants.ts` `APP_VERSION`; submit to docs team. + _(PRD §12.7.)_ + +**Exit criteria:** `verification.enabled` default + billing behavior confirmed with +Product; full matrix green; final review clean; public release shipped. diff --git a/src/__tests__/integration/internationalFlow.test.ts b/src/__tests__/integration/internationalFlow.test.ts index 3be8f51..205c893 100644 --- a/src/__tests__/integration/internationalFlow.test.ts +++ b/src/__tests__/integration/internationalFlow.test.ts @@ -91,6 +91,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "CAN", streetSelector: "#street", }); @@ -131,6 +132,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "CAN", streetSelector: "#street", localitySelector: "#city", @@ -196,6 +198,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "CAN", streetSelector: "#street", localitySelector: "#city", @@ -241,6 +244,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "GBR", streetSelector: "#street", }); @@ -269,6 +273,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "MEX", streetSelector: "#street", }); @@ -328,6 +333,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, countrySelector: "#country", streetSelector: "#street", localitySelector: "#city", @@ -365,6 +371,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "CAN", streetSelector: "#street", localitySelector: "#city", @@ -397,6 +404,7 @@ describe("Integration: International Address Flow", () => { instance = await SmartyAddress.create({ _testMode: true, embeddedKey: "test-key", + verification: { enabled: false }, country: "FRA", streetSelector: "#street", localitySelector: "#city", diff --git a/src/constants.ts b/src/constants.ts index 84f2806..39ed6a1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,82 @@ +import type { VerificationBehavior, VerificationConfig, VerificationResultKey } from "./interfaces"; + export const APP_VERSION = "1.1.0"; export const US_AUTOCOMPLETE_PRO_API_URL = "https://us-autocomplete-pro.api.smarty.com/lookup"; export const INTERNATIONAL_AUTOCOMPLETE_API_URL = "https://international-autocomplete.api.smarty.com/v2/lookup"; +export const US_STREET_API_URL = "https://us-street.api.smarty.com/street-address"; +export const INTERNATIONAL_STREET_API_URL = "https://international-street.api.smarty.com/verify"; + +// Max candidates requested from the US Street API. More than one returned is +// what drives the ambiguous (Type 6) classification (ERD §5.4). +export const US_STREET_CANDIDATE_LIMIT = 10; + export const US_COUNTRY_CODES = ["US", "USA"]; + +// US Street footnote classes that indicate the address was standardized / +// corrected (ERD §5.4). Footnotes arrive as a semicolon-joined string like +// "A#N#" — the leading letter is the class. Presence of any of these on a +// deliverable match means a Type 2 (verified, corrected) result. +export const US_CORRECTION_FOOTNOTE_CLASSES = ["A", "B", "M", "N", "L", "K"]; +// Footnote that marks a deliverable-but-flagged address (vacant/no-stat). +export const US_FLAGGED_FOOTNOTE_CLASS = "R7"; +// Footnote that accompanies a missing-secondary (default) match. +export const US_MISSING_SECONDARY_FOOTNOTE_CLASS = "N1"; + +// International address_precision rank, low → high (ERD §5.4, Q10). Used as a +// fallback when a response omits max_address_precision: precision >= Premise is +// treated as verified-for-country. +export const INTERNATIONAL_PRECISION_RANK = [ + "None", + "Administrative_Area", + "Locality", + "Thoroughfare", + "Premise", + "DeliveryPoint", +]; +export const INTERNATIONAL_MIN_VERIFIED_PRECISION = "Premise"; + +// How long after the last keystroke a `staleness: "revalidate"` re-verify +// fires. Debounced so editing never spends a billable Street call per keystroke +// (ERD §5.6 Q9). +export const REVALIDATE_DEBOUNCE_MS = 800; + +// Per-type allowed behavior overrides (ERD §3.1 — the canonical contract). +// Anything else is warned about by validateConfig and ignored by the +// dispatcher. `error` accepts no override: Type 8 is governed by failureMode. +export const ALLOWED_RESULT_BEHAVIORS: Record = { + verified: ["silent"], + corrected: ["apply-and-notify", "silent", "prompt"], + missingSecondary: ["prompt", "ignore"], + secondaryNotMatched: ["prompt", "apply-primary", "ignore"], + flagged: ["warn", "silent"], + ambiguous: ["prompt", "first-candidate", "ignore"], + undeliverable: ["warn", "block", "silent"], + error: [], +}; + +// Provisional defaults (PRD §6). Every field is a one-line change so locking the +// API does not lock the defaults. `enabled` is gated at Epic 5's public ship. +export const defaultVerificationConfig: Required< + Pick< + VerificationConfig, + "enabled" | "trigger" | "ui" | "failureMode" | "fieldLevelHighlighting" | "staleness" + > +> & { onResult: NonNullable } = { + enabled: true, + trigger: ["selection", "blur"], + ui: "badge", + failureMode: "fail-open", + fieldLevelHighlighting: false, + staleness: "invalidate", + onResult: { + verified: "silent", + corrected: "apply-and-notify", + missingSecondary: "prompt", + secondaryNotMatched: "prompt", + flagged: "warn", + ambiguous: "prompt", + undeliverable: "warn", + }, +}; diff --git a/src/constants/cssClasses.ts b/src/constants/cssClasses.ts index eeaf729..19e8c45 100644 --- a/src/constants/cssClasses.ts +++ b/src/constants/cssClasses.ts @@ -18,6 +18,17 @@ export const CSS_CLASSES = { showAllSecondaries: "smartyAddress__showAllSecondaries", colorDynamic: "smartyAddress__color_dynamic", positionDynamic: "smartyAddress__position_dynamic", + + verifyVars: "smartyAddress__verify_default", + verifyAnnouncer: "smartyAddress__verifyAnnouncer", + verifyBadge: "smartyAddress__verifyBadge", + verifyBadgePositive: "smartyAddress__verifyBadge_positive", + verifyBadgeWarning: "smartyAddress__verifyBadge_warning", + verifyBadgeNegative: "smartyAddress__verifyBadge_negative", + verifyPanel: "smartyAddress__verifyPanel", + verifyPanelMessage: "smartyAddress__verifyPanelMessage", + verifyChooser: "smartyAddress__verifyChooser", + verifyChooserOption: "smartyAddress__verifyChooserOption", } as const; export const CSS_PREFIXES = { diff --git a/src/constants/resultTypeMeta.ts b/src/constants/resultTypeMeta.ts new file mode 100644 index 0000000..82cc496 --- /dev/null +++ b/src/constants/resultTypeMeta.ts @@ -0,0 +1,51 @@ +import type { VerificationResultKey } from "../interfaces"; + +export type ResultTone = "positive" | "warning" | "negative"; + +export interface ResultTypeMeta { + badge: string; + tone: ResultTone; + message: string; +} + +// Display metadata per result type. Mirrors the badge / tone / guidance fields +// in design/verification/app/result-types.jsx (the §7 source of truth). UI copy +// only — classification lives in VerificationService. +export const RESULT_TYPE_META: Record = { + verified: { badge: "Verified", tone: "positive", message: "Address verified." }, + corrected: { + badge: "Adjusted", + tone: "positive", + message: "Adjusted to the official postal form.", + }, + missingSecondary: { + badge: "Needs unit", + tone: "warning", + message: "This building has multiple units — add an apartment or suite number.", + }, + secondaryNotMatched: { + badge: "Check unit", + tone: "warning", + message: "We verified the building but couldn't confirm the unit. Double-check it.", + }, + flagged: { + badge: "Deliverable · flagged", + tone: "warning", + message: "Deliverable, but USPS flags this address. You can continue.", + }, + ambiguous: { + badge: "Multiple matches", + tone: "warning", + message: "More than one address matches — choose one.", + }, + undeliverable: { + badge: "Undeliverable", + tone: "negative", + message: "We couldn't find this address. Double-check it — or submit as entered.", + }, + error: { + badge: "Service error", + tone: "negative", + message: "Verification is temporarily unavailable. Submitting is allowed.", + }, +}; diff --git a/src/index.ts b/src/index.ts index 28abdde..53e1093 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,8 @@ import { DefaultSmartyAddressConfig, SmartyAddressConfig, NormalizedSmartyAddressConfig, + CurrentAddress, + VerificationResult, } from "./interfaces"; import { normalizeConfig } from "./utils/configNormalizer"; import { ApiService } from "./services/ApiService"; @@ -13,8 +15,16 @@ import { FormatService } from "./services/FormatService"; import { DomService } from "./services/DomService"; import { KeyboardNavigationService } from "./services/KeyboardNavigationService"; import { StyleService } from "./services/StyleService"; +import { VerificationService } from "./services/VerificationService"; +import { VerificationUiService } from "./services/VerificationUiService"; +import { VerificationOrchestrator } from "./services/VerificationOrchestrator"; import { themes } from "./themes"; -import { defineStyles, validateConfig } from "./utils/appUtils"; +import { + defineStyles, + isAutocompleteEnabled, + isVerificationEnabled, + validateConfig, +} from "./utils/appUtils"; import { INTERNATIONAL_AUTOCOMPLETE_API_URL, US_AUTOCOMPLETE_PRO_API_URL } from "./constants"; export default class SmartyAddress { @@ -43,6 +53,9 @@ export default class SmartyAddress { DomService, KeyboardNavigationService, StyleService, + VerificationService, + VerificationUiService, + VerificationOrchestrator, }; private static instances: SmartyAddress[] = []; @@ -57,6 +70,11 @@ export default class SmartyAddress { private domService: DomService; private keyboardNavigationService: KeyboardNavigationService; private styleService: StyleService; + private verificationService: VerificationService; + private verificationUiService: VerificationUiService; + private verificationOrchestrator: VerificationOrchestrator; + + private verificationActive = false; static async create(config: SmartyAddressConfig): Promise { const instance = new SmartyAddress(config); @@ -79,6 +97,9 @@ export default class SmartyAddress { this.keyboardNavigationService = new svc.KeyboardNavigationService(); this.dropdownService = new svc.DropdownService(this.instanceId); this.formService = new svc.FormService(); + this.verificationService = new svc.VerificationService(); + this.verificationUiService = new svc.VerificationUiService(); + this.verificationOrchestrator = new svc.VerificationOrchestrator(); const services = { apiService: this.apiService, @@ -90,6 +111,9 @@ export default class SmartyAddress { domService: this.domService, keyboardNavigationService: this.keyboardNavigationService, styleService: this.styleService, + verificationService: this.verificationService, + verificationUiService: this.verificationUiService, + verificationOrchestrator: this.verificationOrchestrator, }; Object.values(services).forEach((service) => service.setServices(services)); @@ -104,11 +128,47 @@ export default class SmartyAddress { validateConfig(mergedConfig); - this.apiService.init(mergedConfig); - this.dropdownService.init(mergedConfig); + const autocompleteOn = isAutocompleteEnabled(mergedConfig); + const verificationOn = isVerificationEnabled(mergedConfig); + + // Neither mode on → the plugin no-ops (PRD §4). validateConfig already warned. + if (!autocompleteOn && !verificationOn) return; + + // FormService is shared: autocomplete populates through it and verification + // reads/round-trips corrections through it. this.formService.init(mergedConfig); + + if (autocompleteOn) { + this.apiService.init(mergedConfig); + this.dropdownService.init(mergedConfig); + } + + if (verificationOn) { + this.verificationActive = true; + this.verificationUiService.init(mergedConfig); + this.verificationService.init(mergedConfig); + this.verificationOrchestrator.init(mergedConfig); + } }; + // Manual / standalone verification entry point (PRD §8, ERD §5.1). + async verify( + address?: CurrentAddress | Partial, + ): Promise { + if (!this.verificationActive) { + console.warn("SmartyAddress: verify() called but verification is not enabled."); + return null; + } + return this.verificationService.verify(address); + } + + // Await-able pre-submit gate (Epic 3, ERD §6). Call this in your submit + // handler: `if (!(await smartyAddress.verifyBeforeSubmit())) return;`. + async verifyBeforeSubmit(): Promise { + if (!this.verificationActive) return true; + return this.verificationService.verifyBeforeSubmit(); + } + destroy(): void { this.apiService.destroy(); this.colorService.destroy(); @@ -119,6 +179,9 @@ export default class SmartyAddress { this.domService.destroy(); this.keyboardNavigationService.destroy(); this.styleService.destroy(); + this.verificationOrchestrator.destroy(); + this.verificationService.destroy(); + this.verificationUiService.destroy(); const index = SmartyAddress.instances.indexOf(this); if (index > -1) { diff --git a/src/interfaces.ts b/src/interfaces.ts index ac82091..2cfc55f 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -7,6 +7,8 @@ import type { FormatService } from "./services/FormatService"; import type { DomService } from "./services/DomService"; import type { KeyboardNavigationService } from "./services/KeyboardNavigationService"; import type { StyleService } from "./services/StyleService"; +import type { VerificationService } from "./services/VerificationService"; +import type { VerificationUiService } from "./services/VerificationUiService"; export interface ApiConfig { embeddedKey: string; @@ -37,6 +39,8 @@ export interface ServiceClassOverrides { DomService?: typeof DomService; KeyboardNavigationService?: typeof KeyboardNavigationService; StyleService?: typeof StyleService; + VerificationService?: typeof VerificationService; + VerificationUiService?: typeof VerificationUiService; } export interface DefaultSmartyAddressConfig extends ApiConfig { @@ -79,6 +83,9 @@ export interface SmartyAddressConfig extends Omit< preferStates?: string[]; preferZipCodes?: string[]; + autocomplete?: AutocompleteConfig; + verification?: VerificationConfig; + /** @internal For testing only - bypasses isTrusted check on events */ _testMode?: boolean; @@ -104,6 +111,9 @@ export interface NormalizedSmartyAddressConfig extends DefaultSmartyAddressConfi administrativeAreaSelector?: string; postalCodeSelector?: string; + autocomplete?: AutocompleteConfig; + verification?: VerificationConfig; + /** @internal For testing only - bypasses isTrusted check on events */ _testMode?: boolean; @@ -128,3 +138,149 @@ export interface AutocompleteSuggestion { address_id?: string; } + +// --------------------------------------------------------------------------- +// Address verification (ERD §3, §4). Additive subsystem alongside autocomplete. +// --------------------------------------------------------------------------- + +export type VerificationTrigger = "selection" | "blur" | "submit" | "manual"; + +export type VerificationBehavior = + | "silent" + | "apply-and-notify" + | "prompt" + | "apply-primary" + | "warn" + | "block" + | "ignore" + | "first-candidate"; + +export type VerificationResultKey = + | "verified" + | "corrected" + | "missingSecondary" + | "secondaryNotMatched" + | "flagged" + | "ambiguous" + | "undeliverable" + | "error"; + +// Deliverability normalized across US + Intl into one enum the dispatcher keys +// on. Raw signals preserved in VerificationResult.raw for debugging/hooks. +export type DeliverabilityCode = + | "deliverable" + | "deliverable-missing-secondary" + | "deliverable-bad-secondary" + | "deliverable-flagged" + | "ambiguous" + | "undeliverable" + | "unknown"; + +export type AddressField = + | "street" + | "secondary" + | "locality" + | "administrativeArea" + | "postalCode" + | "country"; + +// The address being worked with, regardless of where it came from (ERD §4.1). +// This is the seam that lets verification run identically in all three modes. +export interface CurrentAddress { + street: string; + secondary: string; + locality: string; + administrativeArea: string; + postalCode: string; + country: string; + + origin: "autocomplete" | "free-form" | "verification"; + address_id?: string; + verifiedAt?: number; +} + +export interface AddressDiff { + changes: Partial>; + changedFields: AddressField[]; +} + +export interface VerificationResult { + type: VerificationResultKey; + code: DeliverabilityCode; + entered: CurrentAddress; + corrected: CurrentAddress | null; + diff: AddressDiff | null; + candidates?: CurrentAddress[]; + nonBlocking: boolean; + raw: unknown; + source: "us" | "international"; +} + +export interface VerificationError { + kind: "network" | "auth" | "quota" | "parse" | "unknown"; + message: string; + failureMode: "fail-open" | "fail-closed"; + cause?: unknown; +} + +export interface VerificationDecision { + action: "accept" | "reject" | "choose"; + chosen?: CurrentAddress; +} + +export interface VerificationConfig { + enabled?: boolean; + + trigger?: VerificationTrigger[]; + onResult?: Partial>; + + ui?: "none" | "aria-only" | "badge" | "panel"; + + failureMode?: "fail-open" | "fail-closed"; + fieldLevelHighlighting?: boolean; + staleness?: "invalidate" | "revalidate"; + + correctionPrompt?: { style?: "inline-note" | "did-you-mean" | "silent-swap" }; + + usStreetApiUrl?: string; + internationalStreetApiUrl?: string; + + onVerified?: (result: VerificationResult) => void | Promise; + onVerificationFailed?: (error: VerificationError) => void | Promise; + onCorrectionOffered?: ( + diff: AddressDiff, + result: VerificationResult, + ) => void | VerificationDecision | Promise; + onBeforeSubmit?: (result: VerificationResult | null) => boolean | Promise; +} + +// Nested autocomplete block (de-skew, ERD §2 / Q5). Every existing root-level +// autocomplete key keeps working as an alias; this block is additive only. +export interface AutocompleteConfig { + enabled?: boolean; + + streetSelector?: string; + searchInputSelector?: string; + secondarySelector?: string; + localitySelector?: string; + administrativeAreaSelector?: string; + postalCodeSelector?: string; + + country?: string; + countrySelector?: string; + + autocompleteApiUrl?: string; + internationalAutocompleteApiUrl?: string; + maxResults?: number; + + includeOnlyLocalities?: string[]; + includeOnlyAdministrativeAreas?: string[]; + includeOnlyPostalCodes?: string[]; + excludeAdministrativeAreas?: string[]; + preferLocalities?: string[]; + preferAdministrativeAreas?: string[]; + preferPostalCodes?: string[]; + preferRatio?: number; + preferGeolocation?: string; + source?: "postal" | "all"; +} diff --git a/src/services/ApiService.ts b/src/services/ApiService.ts index 3ec8160..ce6518d 100644 --- a/src/services/ApiService.ts +++ b/src/services/ApiService.ts @@ -14,7 +14,7 @@ export interface FetchAutocompleteSuggestionsCallbacks { onError: (errorMessage: string) => void; } -const USER_AGENT = `name:smarty-address-plugin,version:${APP_VERSION}`; +export const USER_AGENT = `name:smarty-address-plugin,version:${APP_VERSION}`; export const US_API_PARAM_MAP = { maxResults: "max_results", diff --git a/src/services/BaseService.ts b/src/services/BaseService.ts index 8f6f32f..0a4e001 100644 --- a/src/services/BaseService.ts +++ b/src/services/BaseService.ts @@ -8,6 +8,9 @@ import type { FormatService } from "./FormatService"; import type { DomService } from "./DomService"; import type { KeyboardNavigationService } from "./KeyboardNavigationService"; import type { StyleService } from "./StyleService"; +import type { VerificationService } from "./VerificationService"; +import type { VerificationUiService } from "./VerificationUiService"; +import type { VerificationOrchestrator } from "./VerificationOrchestrator"; export interface ServiceDependencies { apiService?: ApiService; @@ -19,6 +22,9 @@ export interface ServiceDependencies { domService?: DomService; keyboardNavigationService?: KeyboardNavigationService; styleService?: StyleService; + verificationService?: VerificationService; + verificationUiService?: VerificationUiService; + verificationOrchestrator?: VerificationOrchestrator; } export abstract class BaseService { diff --git a/src/services/FormService.ts b/src/services/FormService.ts index 9408821..984ee60 100644 --- a/src/services/FormService.ts +++ b/src/services/FormService.ts @@ -1,6 +1,11 @@ import { BaseService } from "./BaseService"; -import { AutocompleteSuggestion, NormalizedSmartyAddressConfig } from "../interfaces"; +import { + AutocompleteSuggestion, + CurrentAddress, + NormalizedSmartyAddressConfig, +} from "../interfaces"; import { STATE_ABBREVIATIONS } from "../constants/stateAbbreviations"; +import { toSuggestion } from "../utils/currentAddress"; export class FormService extends BaseService { private streetSelector: string | null = null; @@ -8,6 +13,7 @@ export class FormService extends BaseService { private localitySelector: string | null = null; private administrativeAreaSelector: string | null = null; private postalCodeSelector: string | null = null; + private onPopulated: ((address: AutocompleteSuggestion) => void) | null = null; init(config: NormalizedSmartyAddressConfig) { this.streetSelector = config?.streetSelector ?? null; @@ -17,6 +23,13 @@ export class FormService extends BaseService { this.postalCodeSelector = config?.postalCodeSelector ?? null; } + // The verification orchestrator subscribes here to fire the "selection" + // trigger after an address actually lands in the form (covers US multi-entry + // and the international detail flow, which both finish at populateFormWithAddress). + setOnPopulated(callback: ((address: AutocompleteSuggestion) => void) | null) { + this.onPopulated = callback; + } + getAdministrativeAreaValueForInput(element: HTMLElement, areaValue: string): string { if (!(element instanceof HTMLSelectElement)) { return areaValue; @@ -118,6 +131,11 @@ export class FormService extends BaseService { } populateFormWithAddress(selectedAddress: AutocompleteSuggestion) { + this.applyAddressToForm(selectedAddress); + this.onPopulated?.(selectedAddress); + } + + private applyAddressToForm(selectedAddress: AutocompleteSuggestion) { const domService = this.getService("domService"); const elements = { streetInputElement: domService.findDomElement(this.streetSelector), @@ -159,4 +177,40 @@ export class FormService extends BaseService { domService.setInputValue(elements.postalCodeInputElement, selectedAddress.postalCode); } } + + // Corrections round-trip back through here (ERD §4.1, Q6). Maps the + // CurrentAddress onto the same population path autocomplete already uses. + // Does NOT fire onPopulated — a verification correction must not re-trigger + // the selection flow that produced it. + populateFormWithCurrentAddress(address: CurrentAddress) { + this.applyAddressToForm(toSuggestion(address)); + } + + // fromFormFields adapter (ERD §4.1, Q8). Reads the configured selectors into + // a CurrentAddress for the verification-only / free-form path, where no + // autocomplete suggestion exists. + readCurrentAddress( + country: string, + origin: CurrentAddress["origin"] = "free-form", + ): CurrentAddress { + const domService = this.getService("domService"); + const readValue = (selector: string | null): string => { + if (!selector) return ""; + const element = domService.findDomElement(selector) as + | HTMLInputElement + | HTMLSelectElement + | null; + return element?.value?.trim() ?? ""; + }; + + return { + street: readValue(this.streetSelector), + secondary: readValue(this.secondarySelector), + locality: readValue(this.localitySelector), + administrativeArea: readValue(this.administrativeAreaSelector), + postalCode: readValue(this.postalCodeSelector), + country, + origin, + }; + } } diff --git a/src/services/VerificationOrchestrator.test.ts b/src/services/VerificationOrchestrator.test.ts new file mode 100644 index 0000000..987f48c --- /dev/null +++ b/src/services/VerificationOrchestrator.test.ts @@ -0,0 +1,257 @@ +/** + * @jest-environment jsdom + */ +import SmartyAddress from "../index"; +import type { FormService } from "./FormService"; +import type { VerificationService } from "./VerificationService"; +import type { SmartyAddressConfig } from "../interfaces"; +import { CSS_CLASSES } from "../constants/cssClasses"; + +const candidate = (analysis: Record, components: Record = {}) => ({ + delivery_line_1: "3214 N University Ave", + last_line: "Provo UT 84604", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84604", ...components }, + analysis, +}); + +const verifiedCandidate = candidate({ dpv_match_code: "Y", footnotes: "" }); +const verifiedWithUnitCandidate = candidate( + { dpv_match_code: "Y", footnotes: "" }, + { secondary_designator: "Apt", secondary_number: "4" }, +); +const undeliverableCandidate = candidate({ dpv_match_code: "N", footnotes: "" }); + +const okFetch = (data: unknown): typeof fetch => + (async () => ({ ok: true, status: 200, json: async () => data })) as unknown as typeof fetch; + +const slowFetch = (data: unknown, delayMs: number): typeof fetch => + (() => + new Promise((resolve) => + setTimeout(() => resolve({ ok: true, status: 200, json: async () => data }), delayMs), + )) as unknown as typeof fetch; + +// Two macrotask rounds: the verify settles in microtasks, then the native +// re-submission is deferred one macrotask by wireSubmit. +const flush = () => new Promise((resolve) => setTimeout(() => setTimeout(resolve, 0), 0)); + +const FORM_HTML = ` + + + + + + `; + +interface Internals { + formService: FormService; + verificationService: VerificationService; +} + +async function build( + verification: SmartyAddressConfig["verification"], + fetchFn: typeof fetch, + html: string = FORM_HTML, + selectors: Partial = { + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + }, +) { + document.body.innerHTML = html; + const instance = await SmartyAddress.create({ + embeddedKey: "key", + streetSelector: "#street", + ...selectors, + autocomplete: { enabled: false }, + verification, + _testMode: true, + } as SmartyAddressConfig); + const internals = instance as unknown as Internals; + internals.verificationService.setFetch(fetchFn); + return { instance, internals }; +} + +describe("Orchestrator — selection trigger + merged-field dedupe", () => { + it("verifies on selection and dedupes the blur that follows, even when the street field holds 'street, unit'", async () => { + const fetchFn = jest.fn(okFetch([verifiedWithUnitCandidate])); + const { instance, internals } = await build( + { trigger: ["selection", "blur"] }, + fetchFn as unknown as typeof fetch, + ); + + // No secondarySelector is configured, so population merges the unit into + // the street input ("3214 N University Ave, Apt 4"). + internals.formService.populateFormWithAddress({ + street_line: "3214 N University Ave", + secondary: "Apt 4", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84604", + country: "USA", + }); + await flush(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + document.querySelector("#street")!.dispatchEvent(new FocusEvent("blur")); + await flush(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + instance.destroy(); + }); +}); + +describe("Orchestrator — native
interception", () => { + it("intercepts submit, verifies, and re-submits on pass", async () => { + const { instance } = await build({ trigger: ["submit"] }, okFetch([verifiedCandidate])); + const form = document.querySelector("#form") as HTMLFormElement; + const requestSubmit = jest.fn(); + form.requestSubmit = requestSubmit; + + const event = new Event("submit", { cancelable: true }); + form.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await flush(); + expect(requestSubmit).toHaveBeenCalledTimes(1); + + instance.destroy(); + }); + + it("attaches interception when block is configured even without the submit trigger", async () => { + const { instance } = await build( + { trigger: ["blur"], onResult: { undeliverable: "block" } }, + okFetch([undeliverableCandidate]), + ); + const form = document.querySelector("#form") as HTMLFormElement; + const requestSubmit = jest.fn(); + form.requestSubmit = requestSubmit; + + const event = new Event("submit", { cancelable: true }); + form.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await flush(); + expect(requestSubmit).not.toHaveBeenCalled(); + + instance.destroy(); + }); + + it("keeps intercepting after a re-submit attempt that fired no event (no stuck flag)", async () => { + const { instance } = await build({ trigger: ["submit"] }, okFetch([verifiedCandidate])); + const form = document.querySelector("#form") as HTMLFormElement; + const requestSubmit = jest.fn(); // fires no submit event, like a constraint-validation failure + form.requestSubmit = requestSubmit; + + form.dispatchEvent(new Event("submit", { cancelable: true })); + await flush(); + expect(requestSubmit).toHaveBeenCalledTimes(1); + + const second = new Event("submit", { cancelable: true }); + form.dispatchEvent(second); + expect(second.defaultPrevented).toBe(true); + await flush(); + expect(requestSubmit).toHaveBeenCalledTimes(2); + + instance.destroy(); + }); +}); + +describe("Orchestrator — submit racing an in-flight blur verify", () => { + it("verifyBeforeSubmit awaits the in-flight verify and still blocks", async () => { + const fetchFn = jest.fn(slowFetch([undeliverableCandidate], 20)); + const { instance } = await build( + { trigger: ["blur", "submit"], onResult: { undeliverable: "block" } }, + fetchFn as unknown as typeof fetch, + ); + + document.querySelector("#street")!.dispatchEvent(new FocusEvent("blur")); + const allow = await instance.verifyBeforeSubmit(); + + expect(allow).toBe(false); + expect(fetchFn).toHaveBeenCalledTimes(1); + + instance.destroy(); + }); +}); + +describe("Orchestrator — staleness modes (Q9)", () => { + it("invalidate (default): editing clears the badge and does not auto re-verify", async () => { + jest.useFakeTimers(); + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { instance } = await build({ trigger: ["manual"] }, fetchFn as unknown as typeof fetch); + + await instance.verify(); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).not.toBeNull(); + + const street = document.querySelector("#street") as HTMLInputElement; + street.value = "3215 N University Ave"; + street.dispatchEvent(new Event("input")); + + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + await jest.advanceTimersByTimeAsync(2000); + expect(fetchFn).toHaveBeenCalledTimes(1); + + instance.destroy(); + jest.useRealTimers(); + }); + + it("revalidate: editing re-verifies after the debounce window", async () => { + jest.useFakeTimers(); + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { instance } = await build( + { trigger: ["manual"], staleness: "revalidate" }, + fetchFn as unknown as typeof fetch, + ); + + await instance.verify(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + const street = document.querySelector("#street") as HTMLInputElement; + street.value = "3215 N University Ave"; + street.dispatchEvent(new Event("input")); + + await jest.advanceTimersByTimeAsync(2000); + expect(fetchFn).toHaveBeenCalledTimes(2); + + instance.destroy(); + jest.useRealTimers(); + }); + + it("revalidate debounces: one call after a burst of keystrokes", async () => { + jest.useFakeTimers(); + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { instance } = await build( + { trigger: ["manual"], staleness: "revalidate" }, + fetchFn as unknown as typeof fetch, + ); + + await instance.verify(); + const street = document.querySelector("#street") as HTMLInputElement; + for (const value of ["3215", "3215 N", "3215 N University Ave"]) { + street.value = value; + street.dispatchEvent(new Event("input")); + await jest.advanceTimersByTimeAsync(100); + } + await jest.advanceTimersByTimeAsync(2000); + expect(fetchFn).toHaveBeenCalledTimes(2); + + instance.destroy(); + jest.useRealTimers(); + }); +}); + +describe("Orchestrator — single-field integrations", () => { + it("blur verifies when the only configured field is the street input", async () => { + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { instance } = await build( + { trigger: ["blur"] }, + fetchFn as unknown as typeof fetch, + `
`, + {}, + ); + + document.querySelector("#street")!.dispatchEvent(new FocusEvent("blur")); + await flush(); + expect(fetchFn).toHaveBeenCalledTimes(1); + + instance.destroy(); + }); +}); diff --git a/src/services/VerificationOrchestrator.ts b/src/services/VerificationOrchestrator.ts new file mode 100644 index 0000000..5b4a5df --- /dev/null +++ b/src/services/VerificationOrchestrator.ts @@ -0,0 +1,174 @@ +import { BaseService } from "./BaseService"; +import { REVALIDATE_DEBOUNCE_MS } from "../constants"; +import type { + AutocompleteSuggestion, + NormalizedSmartyAddressConfig, + VerificationTrigger, +} from "../interfaces"; + +// Wires verification triggers to the DOM (ERD §5.6). Kept separate from +// VerificationService so the service stays a pure verify/classify/dispatch unit +// and listener lifecycle lives in one place. +export class VerificationOrchestrator extends BaseService { + private triggers: VerificationTrigger[] = []; + private staleness: "invalidate" | "revalidate" = "invalidate"; + private testMode = false; + private watchedSelectors: string[] = []; + private cleanups: Array<() => void> = []; + private revalidateTimer: ReturnType | null = null; + + init(config: NormalizedSmartyAddressConfig) { + const verificationService = this.getService("verificationService"); + this.triggers = verificationService.getEffectiveConfig().trigger; + this.staleness = verificationService.getEffectiveConfig().staleness; + this.testMode = config._testMode ?? false; + this.watchedSelectors = [ + config.streetSelector, + config.secondarySelector, + config.localitySelector, + config.administrativeAreaSelector, + config.postalCodeSelector, + ].filter((selector): selector is string => !!selector); + + if (this.triggers.includes("selection")) this.wireSelection(); + if (this.triggers.includes("blur")) this.wireBlur(); + // Native interception attaches when the submit trigger is configured OR + // when any configured behavior can block (ERD §6: "when a real
is + // present and block is configured") — a block override must not silently + // fail to block just because the default triggers were kept. + if (this.triggers.includes("submit") || this.blockingConfigured()) this.wireSubmit(); + this.wireStaleness(); + } + + destroy() { + this.cleanups.forEach((cleanup) => cleanup()); + this.cleanups = []; + if (this.revalidateTimer) clearTimeout(this.revalidateTimer); + this.revalidateTimer = null; + this.getService("formService").setOnPopulated(null); + } + + private blockingConfigured(): boolean { + const effective = this.getService("verificationService").getEffectiveConfig(); + return ( + Object.values(effective.onResult).includes("block") || effective.failureMode === "fail-closed" + ); + } + + private wireSelection(): void { + const verificationService = this.getService("verificationService"); + this.getService("formService").setOnPopulated((address: AutocompleteSuggestion) => { + void verificationService.verifyFromSuggestion(address, "selection"); + }); + } + + private wireBlur(): void { + const verificationService = this.getService("verificationService"); + this.forEachWatchedElement((element) => { + const handler = () => { + if (verificationService.isApplyingCorrection()) return; + if (!this.addressLooksComplete()) return; + void verificationService.verifyCurrent("blur"); + }; + element.addEventListener("blur", handler, true); + this.cleanups.push(() => element.removeEventListener("blur", handler, true)); + }); + } + + // Best-effort native interception for a real (ERD §6). The supported + // path is the await-able verifyBeforeSubmit(); this is a convenience for + // vanilla hosts. SPA / non-form hosts must call the method directly. + private wireSubmit(): void { + const form = this.findForm(); + if (!form) return; + + const verificationService = this.getService("verificationService"); + let resubmitting = false; + const handler = async (event: Event) => { + if (resubmitting) return; + event.preventDefault(); + const allow = await verificationService.verifyBeforeSubmit(); + if (!allow) return; + // The re-submission MUST be deferred to a macrotask: a fast verify + // resolves in a microtask checkpoint *between listeners of the original + // submit event*, and the HTML form-submission algorithm silently ignores + // a nested requestSubmit on a form whose submit event is still + // dispatching — the submission would be lost. The flag is reset in the + // finally, not in the re-entrant handler, because requestSubmit may fire + // no event at all (constraint validation) — a stuck flag would skip the + // gate on the next genuine submit. + resubmitting = true; + setTimeout(() => { + try { + if (typeof form.requestSubmit === "function") form.requestSubmit(); + else form.submit(); + } finally { + resubmitting = false; + } + }, 0); + }; + form.addEventListener("submit", handler, true); + this.cleanups.push(() => form.removeEventListener("submit", handler, true)); + } + + private findForm(): HTMLFormElement | null { + const street = this.watchedSelectors[0]; + if (!street) return null; + const element = this.getService("domService").findDomElement(street); + return element?.closest("form") ?? null; + } + + // Staleness on edit (ERD §5.6, Q9). Only user-initiated edits count — + // programmatic corrections dispatch untrusted events. "invalidate" (default) + // clears the verified state and waits for the next configured trigger; + // "revalidate" additionally re-verifies after the user pauses typing. + private wireStaleness(): void { + const verificationService = this.getService("verificationService"); + const revalidates = this.staleness === "revalidate"; + this.forEachWatchedElement((element) => { + const handler = (event: Event) => { + if (verificationService.isApplyingCorrection()) return; + if (!event.isTrusted && !this.testMode) return; + verificationService.markStale(); + if (revalidates) this.scheduleRevalidate(); + }; + element.addEventListener("input", handler); + this.cleanups.push(() => element.removeEventListener("input", handler)); + }); + } + + // Debounced so revalidation never spends a billable call per keystroke — + // the exact risk the ERD flags for silent re-triggering. + private scheduleRevalidate(): void { + if (this.revalidateTimer) clearTimeout(this.revalidateTimer); + this.revalidateTimer = setTimeout(() => { + this.revalidateTimer = null; + if (!this.addressLooksComplete()) return; + void this.getService("verificationService").verifyCurrent("blur"); + }, REVALIDATE_DEBOUNCE_MS); + } + + private forEachWatchedElement(callback: (element: HTMLElement) => void): void { + const domService = this.getService("domService"); + this.watchedSelectors.forEach((selector) => { + const element = domService.findDomElement(selector); + if (element) callback(element); + }); + } + + private addressLooksComplete(): boolean { + const verificationService = this.getService("verificationService"); + const country = verificationService.resolveCountry(); + const address = this.getService("formService").readCurrentAddress(country); + const hasStreet = !!address.street.trim(); + // A single-field integration carries the whole address in the street + // input, so requiring separate region fields would leave the blur + // trigger permanently dead there (PRD §4 mode 2). + const isSingleFieldForm = this.watchedSelectors.length === 1; + if (isSingleFieldForm) return hasStreet; + const hasRegion = + !!address.postalCode.trim() || + (!!address.locality.trim() && !!address.administrativeArea.trim()); + return hasStreet && hasRegion; + } +} diff --git a/src/services/VerificationService.test.ts b/src/services/VerificationService.test.ts new file mode 100644 index 0000000..d1df6a1 --- /dev/null +++ b/src/services/VerificationService.test.ts @@ -0,0 +1,313 @@ +/** + * @jest-environment jsdom + */ +import { VerificationService, parseUsFootnotes } from "./VerificationService"; +import { VerificationUiService } from "./VerificationUiService"; +import { FormService } from "./FormService"; +import { DomService } from "./DomService"; +import type { CurrentAddress, VerificationConfig } from "../interfaces"; + +const entered = (overrides: Partial = {}): CurrentAddress => ({ + street: "3214 N University Ave", + secondary: "", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84604", + country: "USA", + origin: "free-form", + ...overrides, +}); + +// US Street candidate fixtures keyed to the §7 taxonomy. +const candidate = (analysis: Record, components: Record = {}) => ({ + delivery_line_1: "3214 N University Ave", + last_line: "Provo UT 84604", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84604", ...components }, + analysis, +}); + +const verifiedCandidate = candidate({ dpv_match_code: "Y", footnotes: "" }); +const correctedCandidate = candidate( + { dpv_match_code: "Y", footnotes: "A#N#" }, + { plus4_code: "4405" }, +); +const missingSecondaryCandidate = candidate( + { dpv_match_code: "D", footnotes: "N1#" }, + { plus4_code: "7409" }, +); +const badSecondaryCandidate = candidate( + { dpv_match_code: "S", footnotes: "" }, + { plus4_code: "7409" }, +); +const flaggedCandidate = candidate( + { dpv_match_code: "Y", dpv_vacant: "Y", footnotes: "" }, + { plus4_code: "1820" }, +); +const undeliverableCandidate = candidate({ dpv_match_code: "N", footnotes: "" }); + +describe("parseUsFootnotes", () => { + it("splits the #-joined footnote string into class tokens", () => { + expect(parseUsFootnotes("A#N#R7#")).toEqual(new Set(["A", "N", "R7"])); + }); + it("returns an empty set for undefined / empty", () => { + expect(parseUsFootnotes(undefined).size).toBe(0); + expect(parseUsFootnotes("").size).toBe(0); + }); +}); + +describe("VerificationService.classify (US)", () => { + const svc = new VerificationService(); + + const classifyOne = (c: unknown) => svc.classify([c], entered()); + + it("Type 1 — verified, unchanged", () => { + expect(classifyOne(verifiedCandidate).type).toBe("verified"); + }); + + it("Type 2 — verified, corrected (standardization footnotes + ZIP4)", () => { + const result = classifyOne(correctedCandidate); + expect(result.type).toBe("corrected"); + expect(result.corrected?.postalCode).toBe("84604-4405"); + expect(result.diff?.changedFields).toContain("postalCode"); + }); + + it("Type 3 — missing secondary (dpv D)", () => { + expect(classifyOne(missingSecondaryCandidate).type).toBe("missingSecondary"); + }); + + it("Type 4 — secondary not recognized (dpv S)", () => { + expect(classifyOne(badSecondaryCandidate).type).toBe("secondaryNotMatched"); + }); + + it("Type 5 — deliverable but flagged (vacant) takes precedence over corrected", () => { + expect(classifyOne(flaggedCandidate).type).toBe("flagged"); + }); + + it("Type 7 — undeliverable (dpv N)", () => { + expect(classifyOne(undeliverableCandidate).type).toBe("undeliverable"); + }); + + it("Type 7 — undeliverable (zero candidates)", () => { + expect(svc.classify([], entered()).type).toBe("undeliverable"); + }); + + it("Type 6 — ambiguous (multiple deliverable candidates)", () => { + const result = svc.classify([verifiedCandidate, verifiedCandidate], entered()); + expect(result.type).toBe("ambiguous"); + expect(result.candidates).toHaveLength(2); + }); + + it("sets nonBlocking only for flagged / undeliverable / error", () => { + expect(classifyOne(verifiedCandidate).nonBlocking).toBe(false); + expect(classifyOne(flaggedCandidate).nonBlocking).toBe(true); + expect(classifyOne(undeliverableCandidate).nonBlocking).toBe(true); + }); +}); + +const FORM_HTML = ` + + + + + + + `; + +const okFetch = (data: unknown): typeof fetch => + (async () => ({ ok: true, status: 200, json: async () => data })) as unknown as typeof fetch; + +const failFetch = (status: number): typeof fetch => + (async () => ({ ok: false, status, json: async () => ({}) })) as unknown as typeof fetch; + +function setup(verification: VerificationConfig = {}) { + document.body.innerHTML = FORM_HTML; + const domService = new DomService(); + const formService = new FormService(); + const verificationUiService = new VerificationUiService(); + const verificationService = new VerificationService(); + const services = { domService, formService, verificationUiService, verificationService }; + Object.values(services).forEach((service) => service.setServices(services)); + + const config = { + embeddedKey: "key", + streetSelector: "#street", + secondarySelector: "#secondary", + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + autocompleteApiUrl: "", + internationalAutocompleteApiUrl: "", + theme: [], + verification, + } as never; + + formService.init(config); + verificationUiService.init(config); + verificationService.init(config); + + return { verificationService, formService, domService }; +} + +describe("VerificationService dispatch + behavior", () => { + it("applies a Type 2 correction to the form and announces it", async () => { + const onVerified = jest.fn(); + const { verificationService } = setup({ onVerified }); + verificationService.setFetch(okFetch([correctedCandidate])); + + const result = await verificationService.verifyCurrent("manual"); + + expect(result?.type).toBe("corrected"); + expect((document.querySelector("#zip") as HTMLInputElement).value).toBe("84604-4405"); + expect(document.querySelector(".smartyAddress__verifyBadge")?.textContent).toBe("Adjusted"); + expect(onVerified).toHaveBeenCalledTimes(1); + }); + + it("verified result renders a positive badge", async () => { + const { verificationService } = setup(); + verificationService.setFetch(okFetch([verifiedCandidate])); + await verificationService.verifyCurrent("manual"); + expect(document.querySelector(".smartyAddress__verifyBadge_positive")?.textContent).toBe( + "Verified", + ); + }); + + it("honors an onResult override (corrected → silent: no badge)", async () => { + const { verificationService } = setup({ onResult: { corrected: "silent" } }); + verificationService.setFetch(okFetch([correctedCandidate])); + await verificationService.verifyCurrent("manual"); + // silent still applies the correction, but with badge UI the cue is shown. + expect((document.querySelector("#zip") as HTMLInputElement).value).toBe("84604-4405"); + }); + + it("ui: 'none' renders no badge", async () => { + const { verificationService } = setup({ ui: "none" }); + verificationService.setFetch(okFetch([verifiedCandidate])); + await verificationService.verifyCurrent("manual"); + expect(document.querySelector(".smartyAddress__verifyBadge")).toBeNull(); + }); + + it("prompt: a reject decision leaves the entered address untouched (nothing applied before the hook)", async () => { + const onCorrectionOffered = jest.fn().mockResolvedValue({ action: "reject" }); + const { verificationService } = setup({ + onResult: { corrected: "prompt" }, + onCorrectionOffered, + }); + verificationService.setFetch(okFetch([correctedCandidate])); + + await verificationService.verifyCurrent("manual"); + + expect(onCorrectionOffered).toHaveBeenCalled(); + expect((document.querySelector("#zip") as HTMLInputElement).value).toBe("84604"); + }); + + it("prompt: an accept decision applies the correction", async () => { + const onCorrectionOffered = jest.fn().mockResolvedValue({ action: "accept" }); + const { verificationService } = setup({ + onResult: { corrected: "prompt" }, + onCorrectionOffered, + }); + verificationService.setFetch(okFetch([correctedCandidate])); + + await verificationService.verifyCurrent("manual"); + + expect((document.querySelector("#zip") as HTMLInputElement).value).toBe("84604-4405"); + }); + + it("type 4 prompt keeps the entered unit instead of overwriting it (PRD §7 row 4)", async () => { + const { verificationService } = setup(); + (document.querySelector("#secondary") as HTMLInputElement).value = "Apt 9"; + verificationService.setFetch(okFetch([badSecondaryCandidate])); + + const result = await verificationService.verifyCurrent("manual"); + + expect(result?.type).toBe("secondaryNotMatched"); + expect((document.querySelector("#secondary") as HTMLInputElement).value).toBe("Apt 9"); + }); + + it("nonBlocking reflects a block override on type 7", async () => { + const { verificationService } = setup({ onResult: { undeliverable: "block" } }); + verificationService.setFetch(okFetch([undeliverableCandidate])); + const result = await verificationService.verifyCurrent("manual"); + expect(result?.nonBlocking).toBe(false); + }); + + it("drops a disallowed onResult override instead of dispatching it", () => { + const { verificationService } = setup({ onResult: { verified: "block" } } as never); + expect(verificationService.getEffectiveConfig().onResult.verified).toBe("silent"); + }); +}); + +describe("VerificationService dedupe + staleness", () => { + it("dedupes the selection→blur double call (same fingerprint)", async () => { + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { verificationService } = setup(); + verificationService.setFetch(fetchFn as unknown as typeof fetch); + + const first = await verificationService.verifyCurrent("selection"); + const second = await verificationService.verifyCurrent("blur"); + + expect(second).toBe(first); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("re-verifies after markStale()", async () => { + const fetchFn = jest.fn(okFetch([verifiedCandidate])); + const { verificationService } = setup(); + verificationService.setFetch(fetchFn as unknown as typeof fetch); + + await verificationService.verifyCurrent("selection"); + verificationService.markStale(); + await verificationService.verifyCurrent("blur"); + + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("sets verifiedAt on a successful verify and clears it on staleness", async () => { + const { verificationService } = setup(); + verificationService.setFetch(okFetch([verifiedCandidate])); + + const result = await verificationService.verifyCurrent("manual"); + expect(typeof result?.entered.verifiedAt).toBe("number"); + expect(typeof result?.corrected?.verifiedAt).toBe("number"); + + verificationService.markStale(); + expect(result?.entered.verifiedAt).toBeUndefined(); + expect(result?.corrected?.verifiedAt).toBeUndefined(); + }); + + it("tracks the corrected fingerprint so a follow-up blur on corrected values is deduped", async () => { + const fetchFn = jest.fn(okFetch([correctedCandidate])); + const { verificationService } = setup(); + verificationService.setFetch(fetchFn as unknown as typeof fetch); + + const first = await verificationService.verifyCurrent("selection"); // applies 84604-4405 to the form + const second = await verificationService.verifyCurrent("blur"); // reads corrected values + + expect(second).toBe(first); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("VerificationService error handling", () => { + it("fail-open by default: Type 8 error, non-blocking, onVerificationFailed fired", async () => { + const onVerificationFailed = jest.fn(); + const { verificationService } = setup({ onVerificationFailed }); + verificationService.setFetch(failFetch(429)); + + const result = await verificationService.verifyCurrent("manual"); + + expect(result?.type).toBe("error"); + expect(result?.nonBlocking).toBe(true); + expect(onVerificationFailed).toHaveBeenCalledWith( + expect.objectContaining({ kind: "quota", failureMode: "fail-open" }), + ); + }); + + it("fail-closed marks the error result blocking", async () => { + const { verificationService } = setup({ failureMode: "fail-closed" }); + verificationService.setFetch(failFetch(500)); + const result = await verificationService.verifyCurrent("manual"); + expect(result?.type).toBe("error"); + expect(result?.nonBlocking).toBe(false); + }); +}); diff --git a/src/services/VerificationService.ts b/src/services/VerificationService.ts new file mode 100644 index 0000000..7d6a87e --- /dev/null +++ b/src/services/VerificationService.ts @@ -0,0 +1,775 @@ +import { BaseService } from "./BaseService"; +import { fetchStreetJson, StreetApiError } from "./http/streetTransport"; +import { computeDiff, fingerprint, fromSuggestion } from "../utils/currentAddress"; +import { + ALLOWED_RESULT_BEHAVIORS, + defaultVerificationConfig, + INTERNATIONAL_MIN_VERIFIED_PRECISION, + INTERNATIONAL_PRECISION_RANK, + INTERNATIONAL_STREET_API_URL, + US_CORRECTION_FOOTNOTE_CLASSES, + US_COUNTRY_CODES, + US_FLAGGED_FOOTNOTE_CLASS, + US_STREET_API_URL, + US_STREET_CANDIDATE_LIMIT, +} from "../constants"; +import type { + AutocompleteSuggestion, + CurrentAddress, + DeliverabilityCode, + NormalizedSmartyAddressConfig, + VerificationBehavior, + VerificationConfig, + VerificationDecision, + VerificationError, + VerificationResult, + VerificationResultKey, + VerificationTrigger, +} from "../interfaces"; + +interface UsStreetComponents { + primary_number?: string; + street_name?: string; + secondary_number?: string; + secondary_designator?: string; + city_name?: string; + state_abbreviation?: string; + zipcode?: string; + plus4_code?: string; +} + +interface UsStreetAnalysis { + dpv_match_code?: string; + dpv_vacant?: string; + dpv_no_stat?: string; + dpv_cmra?: string; + footnotes?: string; +} + +interface UsStreetCandidate { + delivery_line_1?: string; + delivery_line_2?: string; + last_line?: string; + components?: UsStreetComponents; + analysis?: UsStreetAnalysis; +} + +interface InternationalChanges { + sub_building?: string; + [component: string]: string | undefined; +} + +interface InternationalAnalysis { + verification_status?: string; + address_precision?: string; + max_address_precision?: string; + changes?: InternationalChanges; +} + +interface InternationalComponents { + thoroughfare?: string; + premise?: string; + sub_building?: string; + locality?: string; + administrative_area?: string; + postal_code?: string; + country_iso3?: string; +} + +interface InternationalStreetCandidate { + address1?: string; + address2?: string; + components?: InternationalComponents; + analysis?: InternationalAnalysis; +} + +const INTL_CORRECTION_CHANGES = ["Verified-SmallChange", "Added"]; + +const precisionRank = (precision: string | undefined): number => { + const index = INTERNATIONAL_PRECISION_RANK.indexOf(precision ?? "None"); + return index === -1 ? 0 : index; +}; + +// Q10 / ERD §5.4 — "verified" means reaching the country's max precision, not a +// hard DeliveryPoint. Compare to the per-response max_address_precision; fall +// back to a fixed minimum when the response omits it. +const reachedCountryMaxPrecision = (analysis: InternationalAnalysis): boolean => { + if (analysis.max_address_precision) { + return ( + precisionRank(analysis.address_precision) >= precisionRank(analysis.max_address_precision) + ); + } + console.warn( + "SmartyAddress: international response omitted max_address_precision; " + + `treating address_precision >= ${INTERNATIONAL_MIN_VERIFIED_PRECISION} as verified for this country.`, + ); + return ( + precisionRank(analysis.address_precision) >= precisionRank(INTERNATIONAL_MIN_VERIFIED_PRECISION) + ); +}; + +interface EffectiveVerificationConfig { + enabled: boolean; + trigger: VerificationTrigger[]; + onResult: NonNullable; + ui: NonNullable; + failureMode: NonNullable; + fieldLevelHighlighting: boolean; + staleness: NonNullable; + correctionPrompt?: VerificationConfig["correctionPrompt"]; + hooks: { + onVerified?: VerificationConfig["onVerified"]; + onVerificationFailed?: VerificationConfig["onVerificationFailed"]; + onCorrectionOffered?: VerificationConfig["onCorrectionOffered"]; + onBeforeSubmit?: VerificationConfig["onBeforeSubmit"]; + }; +} + +const TYPE_TO_CODE: Record = { + verified: "deliverable", + corrected: "deliverable", + missingSecondary: "deliverable-missing-secondary", + secondaryNotMatched: "deliverable-bad-secondary", + flagged: "deliverable-flagged", + ambiguous: "ambiguous", + undeliverable: "undeliverable", + error: "unknown", +}; + +const NON_BLOCKING_TYPES: VerificationResultKey[] = ["flagged", "undeliverable", "error"]; + +// Footnotes arrive as a "#"-joined string (e.g. "A#N#R7#"). ERD §5.4. +export function parseUsFootnotes(footnotes: string | undefined): Set { + if (!footnotes) return new Set(); + return new Set( + footnotes + .split("#") + .map((token) => token.trim()) + .filter(Boolean), + ); +} + +const hasCorrectionFootnote = (footnotes: Set): boolean => { + for (const token of footnotes) { + if (US_CORRECTION_FOOTNOTE_CLASSES.includes(token.charAt(0))) return true; + } + return false; +}; + +export class VerificationService extends BaseService { + private embeddedKey = ""; + private usStreetApiUrl = US_STREET_API_URL; + private internationalStreetApiUrl = INTERNATIONAL_STREET_API_URL; + private staticCountry: string | undefined; + private countrySelector: string | undefined; + private fetchFn: typeof fetch = (...args: Parameters) => fetch(...args); + + private effective: EffectiveVerificationConfig = this.buildEffectiveConfig({}); + + private inFlight: { fp: string; promise: Promise } | null = null; + private verifiedFingerprints = new Set(); + private stale = false; + private lastResult: VerificationResult | null = null; + private applyingCorrection = false; + + init(config: NormalizedSmartyAddressConfig) { + this.embeddedKey = config.embeddedKey; + const verification = config.verification ?? {}; + this.effective = this.buildEffectiveConfig(verification); + this.usStreetApiUrl = verification.usStreetApiUrl ?? US_STREET_API_URL; + this.internationalStreetApiUrl = + verification.internationalStreetApiUrl ?? INTERNATIONAL_STREET_API_URL; + this.staticCountry = config.country?.trim() || undefined; + this.countrySelector = config.countrySelector; + } + + destroy() { + this.inFlight = null; + this.verifiedFingerprints.clear(); + this.lastResult = null; + this.stale = false; + } + + /** @internal Inject fetch for tests. */ + setFetch(fetchFn: typeof fetch) { + this.fetchFn = fetchFn; + } + + getEffectiveConfig(): EffectiveVerificationConfig { + return this.effective; + } + + getLastResult(): VerificationResult | null { + return this.lastResult; + } + + private buildEffectiveConfig(verification: VerificationConfig): EffectiveVerificationConfig { + // Disallowed per-type overrides are dropped (validateConfig already warned) + // so dispatch never acts on a behavior the type doesn't support (ERD §3.1). + const allowedOverrides = Object.fromEntries( + Object.entries(verification.onResult ?? {}).filter(([type, behavior]) => + ALLOWED_RESULT_BEHAVIORS[type as VerificationResultKey]?.includes( + behavior as VerificationBehavior, + ), + ), + ); + return { + enabled: verification.enabled ?? defaultVerificationConfig.enabled, + trigger: verification.trigger ?? defaultVerificationConfig.trigger, + onResult: { ...defaultVerificationConfig.onResult, ...allowedOverrides }, + ui: verification.ui ?? defaultVerificationConfig.ui, + failureMode: verification.failureMode ?? defaultVerificationConfig.failureMode, + fieldLevelHighlighting: + verification.fieldLevelHighlighting ?? defaultVerificationConfig.fieldLevelHighlighting, + staleness: verification.staleness ?? defaultVerificationConfig.staleness, + correctionPrompt: verification.correctionPrompt, + hooks: { + onVerified: verification.onVerified, + onVerificationFailed: verification.onVerificationFailed, + onCorrectionOffered: verification.onCorrectionOffered, + onBeforeSubmit: verification.onBeforeSubmit, + }, + }; + } + + resolveCountry(): string { + if (this.countrySelector) { + const element = this.getService("domService").findDomElement(this.countrySelector) as + | HTMLInputElement + | HTMLSelectElement + | null; + const fromSelector = element?.value?.trim(); + if (fromSelector) return fromSelector; + } + return this.staticCountry ?? "USA"; + } + + isInternational(country: string): boolean { + return !!country && !US_COUNTRY_CODES.includes(country.toUpperCase()); + } + + // Manual / standalone entry (PRD §8). + async verify( + address?: CurrentAddress | Partial, + ): Promise { + const country = address?.country?.trim() || this.resolveCountry(); + const entered = this.resolveEntered(address, country); + return this.runFlow(entered, "manual"); + } + + verifyFromSuggestion( + suggestion: AutocompleteSuggestion, + trigger: VerificationTrigger, + ): Promise { + return this.runFlow(fromSuggestion(suggestion), trigger); + } + + verifyCurrent(trigger: VerificationTrigger): Promise { + const country = this.resolveCountry(); + const entered = this.getService("formService").readCurrentAddress(country); + return this.runFlow(entered, trigger); + } + + // Await-able pre-submit gate (Epic 3 / ERD §6). The supported cross-framework + // blocking path: the integrator awaits this in their own submit handler and + // gets back `true` (ok to submit) / `false` (block). Verifies the current + // address, applies the configured block / fail-closed policy, then lets an + // onBeforeSubmit hook have the final say. + async verifyBeforeSubmit(): Promise { + const country = this.resolveCountry(); + const entered = this.getService("formService").readCurrentAddress(country); + const fresh = await this.runFlow(entered, "submit"); + // An empty form has nothing to gate on — never fall back to a previous + // address's result there. + const result = fresh ?? (this.isEmptyAddress(entered) ? null : this.lastResult); + + let allow = this.resolveSubmitDecision(result); + const hook = this.effective.hooks.onBeforeSubmit; + if (hook) { + const hookAllow = await hook(result ?? null); + allow = allow && hookAllow !== false; + } + return allow; + } + + private resolveSubmitDecision(result: VerificationResult | null): boolean { + if (!result) return true; + if (result.type === "error") return this.effective.failureMode !== "fail-closed"; + return this.behaviorFor(result.type) !== "block"; + } + + private resolveEntered( + address: CurrentAddress | Partial | undefined, + country: string, + ): CurrentAddress { + if (!address) { + return this.getService("formService").readCurrentAddress(country); + } + return { + street: address.street ?? "", + secondary: address.secondary ?? "", + locality: address.locality ?? "", + administrativeArea: address.administrativeArea ?? "", + postalCode: address.postalCode ?? "", + country, + origin: address.origin ?? "verification", + ...(address.address_id ? { address_id: address.address_id } : {}), + }; + } + + private async runFlow( + entered: CurrentAddress, + trigger: VerificationTrigger, + ): Promise { + if (this.isEmptyAddress(entered)) return null; + + const country = entered.country; + const international = this.isInternational(country); + + // International verification ships in Epic 4 (R4). Until then it is a + // no-op rather than a fake error. See VerificationService Epic 4 branch. + if (international && !this.internationalEnabled()) { + console.warn("SmartyAddress: international verification is not available in this release."); + return null; + } + + const fp = fingerprint(entered); + // A concurrent trigger on the same address shares the in-flight call + // instead of getting null — a submit racing a blur verify must wait for + // the real result, or `block` silently fails open (ERD §6). + if (this.inFlight?.fp === fp) return this.inFlight.promise; + if (!this.stale && this.verifiedFingerprints.has(fp)) return this.lastResult; + + const core = this.verifyCore(entered, international); + this.inFlight = { fp, promise: core }; + let result: VerificationResult; + try { + result = await core; + } finally { + if (this.inFlight?.promise === core) this.inFlight = null; + } + if (result.type !== "error") await this.dispatch(result, trigger); + return result; + } + + // Fetch + classify + record: the portion shared with concurrent same-address + // triggers. Dispatch (UI + hooks) runs once, in the initiating caller. + private async verifyCore( + entered: CurrentAddress, + international: boolean, + ): Promise { + try { + const raw = international + ? await this.fetchInternational(entered) + : await this.fetchUs(entered); + const result = this.classify(raw, entered); + this.recordVerified(entered, result); + return result; + } catch (error) { + return this.handleError(error, entered, international); + } + } + + private recordVerified(entered: CurrentAddress, result: VerificationResult): void { + this.lastResult = result; + this.stale = false; + this.verifiedFingerprints = new Set([fingerprint(entered)]); + if (result.corrected) this.verifiedFingerprints.add(fingerprint(result.corrected)); + + const verifiedAt = Date.now(); + result.entered.verifiedAt = verifiedAt; + if (result.corrected) result.corrected.verifiedAt = verifiedAt; + } + + protected internationalEnabled(): boolean { + return true; + } + + private isEmptyAddress(address: CurrentAddress): boolean { + return !address.street?.trim() && !address.postalCode?.trim() && !address.locality?.trim(); + } + + private async fetchUs(entered: CurrentAddress): Promise { + const params: Record = { + key: this.embeddedKey, + candidates: String(US_STREET_CANDIDATE_LIMIT), + match: "enhanced", + }; + if (entered.street) params.street = entered.street; + if (entered.secondary) params.secondary = entered.secondary; + if (entered.locality) params.city = entered.locality; + if (entered.administrativeArea) params.state = entered.administrativeArea; + if (entered.postalCode) params.zipcode = entered.postalCode; + + return fetchStreetJson(this.usStreetApiUrl, params, this.fetchFn); + } + + // International ordering (ERD §5.3): in "both" mode the autocomplete detail + // fetch has already populated full components into the CurrentAddress before + // this runs, so verify always has complete components to send. + protected async fetchInternational( + entered: CurrentAddress, + ): Promise { + const params: Record = { + key: this.embeddedKey, + country: entered.country, + }; + if (entered.street) params.address1 = entered.street; + if (entered.secondary) params.address2 = entered.secondary; + if (entered.locality) params.locality = entered.locality; + if (entered.administrativeArea) params.administrative_area = entered.administrativeArea; + if (entered.postalCode) params.postal_code = entered.postalCode; + + return fetchStreetJson( + this.internationalStreetApiUrl, + params, + this.fetchFn, + ); + } + + classify(raw: unknown, entered: CurrentAddress): VerificationResult { + if (entered.country && this.isInternational(entered.country)) { + return this.classifyInternational(raw, entered); + } + return this.classifyUs((raw as UsStreetCandidate[]) ?? [], entered); + } + + private classifyUs(candidates: UsStreetCandidate[], entered: CurrentAddress): VerificationResult { + const first = candidates[0]; + if (!first) return this.makeResult("undeliverable", entered, null, null, "us"); + + const analysis = first.analysis ?? {}; + const dpv = (analysis.dpv_match_code ?? "").toUpperCase(); + const footnotes = parseUsFootnotes(analysis.footnotes); + const deliverable = ["Y", "S", "D"].includes(dpv); + const corrected = this.usCandidateToAddress(first, entered); + const diff = computeDiff(entered, corrected); + + if (dpv === "N") return this.makeResult("undeliverable", entered, null, null, "us", first); + + const flaggedSignal = + analysis.dpv_vacant === "Y" || + analysis.dpv_no_stat === "Y" || + footnotes.has(US_FLAGGED_FOOTNOTE_CLASS); + if (deliverable && flaggedSignal) { + return this.makeResult("flagged", entered, corrected, diff, "us", first); + } + + if (dpv === "D") + return this.makeResult("missingSecondary", entered, corrected, diff, "us", first); + if (dpv === "S") + return this.makeResult("secondaryNotMatched", entered, corrected, diff, "us", first); + + if (candidates.length > 1) { + const candidateAddresses = candidates.map((candidate) => + this.usCandidateToAddress(candidate, entered), + ); + const result = this.makeResult("ambiguous", entered, null, null, "us", candidates); + result.candidates = candidateAddresses; + return result; + } + + if (deliverable && hasCorrectionFootnote(footnotes)) { + return this.makeResult("corrected", entered, corrected, diff, "us", first); + } + + if (dpv === "Y" && !diff) { + return this.makeResult("verified", entered, corrected, null, "us", first); + } + if (dpv === "Y") { + // Y with component differences we didn't footnote-detect: still corrected. + return this.makeResult("corrected", entered, corrected, diff, "us", first); + } + + return this.makeResult("undeliverable", entered, null, null, "us", first); + } + + // International branch (ERD §5.4). Same VerificationResultKey outputs as US; + // only the input signals differ. Type 5 (flagged) is USPS-specific and never + // fires here. + protected classifyInternational(raw: unknown, entered: CurrentAddress): VerificationResult { + const candidates = (raw as InternationalStreetCandidate[]) ?? []; + const first = candidates[0]; + if (!first) return this.makeResult("undeliverable", entered, null, null, "international"); + + const analysis = first.analysis ?? {}; + const status = analysis.verification_status ?? ""; + const corrected = this.intlCandidateToAddress(first, entered); + const diff = computeDiff(entered, corrected); + + if (status === "None" || analysis.address_precision === "None") { + return this.makeResult("undeliverable", entered, null, null, "international", first); + } + + // Unlike the US branch, multiple candidates alone do not signal ambiguity + // internationally — only verification_status does (PRD §7 row 6). + if (status === "Ambiguous") { + const candidateAddresses = candidates.map((candidate) => + this.intlCandidateToAddress(candidate, entered), + ); + const result = this.makeResult("ambiguous", entered, null, null, "international", candidates); + result.candidates = candidateAddresses; + return result; + } + + const changes = analysis.changes ?? {}; + if (changes.sub_building === "Unrecognized") { + return this.makeResult( + "secondaryNotMatched", + entered, + corrected, + diff, + "international", + first, + ); + } + + const subBuildingAbsent = !corrected.secondary && !changes.sub_building; + if (status === "Partial" && analysis.address_precision === "Premise" && subBuildingAbsent) { + return this.makeResult("missingSecondary", entered, corrected, diff, "international", first); + } + + if (status === "Verified") { + const hasCorrection = Object.values(changes).some((change) => + INTL_CORRECTION_CHANGES.includes(change ?? ""), + ); + if (hasCorrection) { + return this.makeResult("corrected", entered, corrected, diff, "international", first); + } + if (reachedCountryMaxPrecision(analysis)) { + return this.makeResult("verified", entered, corrected, null, "international", first); + } + // Verified status but below the country's max precision — treat as + // missing detail rather than verified (Q10 boundary). + return this.makeResult("missingSecondary", entered, corrected, diff, "international", first); + } + + return this.makeResult("undeliverable", entered, null, null, "international", first); + } + + private intlCandidateToAddress( + candidate: InternationalStreetCandidate, + entered: CurrentAddress, + ): CurrentAddress { + const components = candidate.components ?? {}; + return { + street: candidate.address1 ?? components.thoroughfare ?? entered.street, + secondary: components.sub_building ?? "", + locality: components.locality ?? entered.locality, + administrativeArea: components.administrative_area ?? entered.administrativeArea, + postalCode: components.postal_code ?? entered.postalCode, + country: components.country_iso3 ?? entered.country, + origin: "verification", + }; + } + + private usCandidateToAddress( + candidate: UsStreetCandidate, + entered: CurrentAddress, + ): CurrentAddress { + const components = candidate.components ?? {}; + const zip = components.zipcode ?? ""; + const plus4 = components.plus4_code ?? ""; + const postalCode = zip && plus4 ? `${zip}-${plus4}` : zip || entered.postalCode; + const secondary = [components.secondary_designator, components.secondary_number] + .filter(Boolean) + .join(" "); + + return { + street: candidate.delivery_line_1 ?? entered.street, + secondary: secondary || (candidate.delivery_line_2 ?? ""), + locality: components.city_name ?? entered.locality, + administrativeArea: components.state_abbreviation ?? entered.administrativeArea, + postalCode, + country: entered.country, + origin: "verification", + }; + } + + private makeResult( + type: VerificationResultKey, + entered: CurrentAddress, + corrected: CurrentAddress | null, + diff: VerificationResult["diff"], + source: "us" | "international", + raw: unknown = null, + ): VerificationResult { + return { + type, + code: TYPE_TO_CODE[type], + entered, + corrected, + diff, + nonBlocking: this.isNonBlocking(type), + raw, + source, + }; + } + + // Types 5/7/8 never gate submit by default (ERD §4) — unless the configured + // behavior says otherwise (block override on type 7, fail-closed on type 8). + // The field must agree with what resolveSubmitDecision will actually do. + private isNonBlocking(type: VerificationResultKey): boolean { + if (type === "error") return this.effective.failureMode !== "fail-closed"; + if (!NON_BLOCKING_TYPES.includes(type)) return false; + return this.behaviorFor(type) !== "block"; + } + + behaviorFor(type: VerificationResultKey): VerificationBehavior { + return this.effective.onResult[type] ?? defaultVerificationConfig.onResult[type] ?? "ignore"; + } + + private async dispatch(result: VerificationResult, _trigger: VerificationTrigger): Promise { + const behavior = this.behaviorFor(result.type); + + const offersCorrection = ( + [ + "corrected", + "missingSecondary", + "secondaryNotMatched", + "ambiguous", + ] as VerificationResultKey[] + ).includes(result.type); + + // A customer hook returning a decision overrides the built-in apply AND + // the built-in UI (ERD §7). It must run before anything touches the form, + // so a "reject" decision leaves the entered address exactly as the user + // typed it (ERD §5.5 — prompt awaits the decision). + if (behavior === "prompt" && offersCorrection && this.effective.hooks.onCorrectionOffered) { + const decision = await this.effective.hooks.onCorrectionOffered( + result.diff ?? { changes: {}, changedFields: [] }, + result, + ); + if (decision) { + this.applyDecision(decision, result); + await this.effective.hooks.onVerified?.(result); + return; + } + } + + this.applyBehavior(result, behavior); + this.renderResult(result, behavior); + await this.effective.hooks.onVerified?.(result); + } + + private renderResult(result: VerificationResult, behavior: VerificationBehavior): void { + // "no-op beyond recording the result" (ERD §5.5) — no surface, no announce. + if (behavior === "ignore") return; + + const ui = this.getService("verificationUiService"); + const isChooser = + result.type === "ambiguous" && behavior === "prompt" && !!result.candidates?.length; + if (isChooser) { + ui.renderChooser(result, this.effective, (chosen) => this.applyToForm(chosen)); + return; + } + + const autoPicked = result.candidates?.[0]; + if (result.type === "ambiguous" && behavior === "first-candidate" && autoPicked) { + // The auto-pick swapped the address; present it as a correction rather + // than asking the user to choose (PRD §7 — never a silent swap). + ui.render({ ...result, type: "corrected", corrected: autoPicked }, behavior, this.effective); + return; + } + + ui.render(result, behavior, this.effective); + } + + private applyBehavior(result: VerificationResult, behavior: VerificationBehavior): void { + if (behavior === "ignore" || behavior === "block") return; + + // An unconfirmed unit must never be dropped (PRD §7 row 4): both the + // explicit apply-primary override and the default prompt on type 4 apply + // the corrected primary while keeping the user's entered secondary. + const keepsEnteredUnit = + behavior === "apply-primary" || + (behavior === "prompt" && result.type === "secondaryNotMatched"); + if (keepsEnteredUnit && result.corrected) { + this.applyToForm({ ...result.corrected, secondary: result.entered.secondary }); + return; + } + + const firstCandidate = result.candidates?.[0]; + if (behavior === "first-candidate" && firstCandidate) { + this.applyToForm(firstCandidate); + return; + } + + const applies = ["silent", "apply-and-notify", "prompt", "warn"].includes(behavior); + if (applies && result.corrected) { + this.applyToForm(result.corrected); + } + } + + private applyDecision(decision: VerificationDecision, result: VerificationResult): void { + if (decision.action === "reject") return; + if (decision.action === "choose" && decision.chosen) { + this.applyToForm(decision.chosen); + return; + } + if (decision.action === "accept" && result.corrected) { + this.applyToForm(result.corrected); + } + } + + private applyToForm(address: CurrentAddress): void { + this.applyingCorrection = true; + try { + this.getService("formService").populateFormWithCurrentAddress(address); + } finally { + this.applyingCorrection = false; + } + // What we just wrote into the form is verified by construction — record + // it so the blur that follows a chooser pick / first-candidate apply / + // hook decision doesn't fire a redundant billable call (ERD §5.6). + this.verifiedFingerprints.add(fingerprint(address)); + this.stale = false; + } + + private async handleError( + error: unknown, + entered: CurrentAddress, + international: boolean, + ): Promise { + const failureMode = this.effective.failureMode; + const verificationError: VerificationError = { + kind: error instanceof StreetApiError ? error.kind : "unknown", + message: error instanceof Error ? error.message : String(error), + failureMode, + cause: error, + }; + + const result = this.makeResult( + "error", + entered, + null, + null, + international ? "international" : "us", + error, + ); + this.lastResult = result; + + this.getService("verificationUiService").render(result, "ignore", this.effective); + await this.effective.hooks.onVerificationFailed?.(verificationError); + return result; + } + + isStale(address: CurrentAddress): boolean { + return this.stale || !this.verifiedFingerprints.has(fingerprint(address)); + } + + isApplyingCorrection(): boolean { + return this.applyingCorrection; + } + + markStale(): void { + if (this.verifiedFingerprints.size === 0) return; + this.stale = true; + this.verifiedFingerprints.clear(); + if (this.lastResult) { + delete this.lastResult.entered.verifiedAt; + if (this.lastResult.corrected) delete this.lastResult.corrected.verifiedAt; + } + this.getService("verificationUiService").clear(); + } +} diff --git a/src/services/VerificationUiService.test.ts b/src/services/VerificationUiService.test.ts new file mode 100644 index 0000000..d4b7f6e --- /dev/null +++ b/src/services/VerificationUiService.test.ts @@ -0,0 +1,82 @@ +/** + * @jest-environment jsdom + */ +import { VerificationUiService } from "./VerificationUiService"; +import { DomService } from "./DomService"; +import { CSS_CLASSES } from "../constants/cssClasses"; +import type { CurrentAddress, VerificationResult } from "../interfaces"; + +const address: CurrentAddress = { + street: "3214 N University Ave", + secondary: "", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84604-4405", + country: "USA", + origin: "verification", +}; + +const result = (overrides: Partial = {}): VerificationResult => ({ + type: "verified", + code: "deliverable", + entered: { ...address, postalCode: "84604" }, + corrected: address, + diff: null, + nonBlocking: false, + raw: null, + source: "us", + ...overrides, +}); + +function setup() { + document.body.innerHTML = ``; + const domService = new DomService(); + const ui = new VerificationUiService(); + const services = { domService, verificationUiService: ui }; + ui.setServices(services); + domService.setServices(services); + ui.init({ streetSelector: "#street" } as never); + return ui; +} + +describe("VerificationUiService", () => { + it("badge surface renders the cue next to the street field", () => { + const ui = setup(); + ui.render(result(), "silent", { ui: "badge" }); + const badge = document.querySelector(`.${CSS_CLASSES.verifyBadge}`); + expect(badge?.textContent).toBe("Verified"); + expect(document.querySelector("#street")?.nextElementSibling).toBe(badge); + }); + + it("aria-only surface announces but renders no badge", () => { + const ui = setup(); + ui.render(result({ type: "corrected" }), "apply-and-notify", { ui: "aria-only" }); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + const announcer = document.querySelector(`.${CSS_CLASSES.verifyAnnouncer}`); + expect(announcer?.getAttribute("aria-live")).toBe("polite"); + expect(announcer?.textContent).toContain("Adjusted"); + }); + + it("none surface renders nothing and does not announce", () => { + const ui = setup(); + ui.render(result(), "silent", { ui: "none" }); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + expect(document.querySelector(`.${CSS_CLASSES.verifyAnnouncer}`)).toBeNull(); + }); + + it("error (Type 8) is always aria-only regardless of configured surface", () => { + const ui = setup(); + ui.render(result({ type: "error", corrected: null }), "ignore", { ui: "badge" }); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + expect(document.querySelector(`.${CSS_CLASSES.verifyAnnouncer}`)?.textContent).toContain( + "temporarily unavailable", + ); + }); + + it("clear() removes a previously rendered badge", () => { + const ui = setup(); + ui.render(result(), "silent", { ui: "badge" }); + ui.clear(); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + }); +}); diff --git a/src/services/VerificationUiService.ts b/src/services/VerificationUiService.ts new file mode 100644 index 0000000..142d458 --- /dev/null +++ b/src/services/VerificationUiService.ts @@ -0,0 +1,203 @@ +import { BaseService } from "./BaseService"; +import { CSS_CLASSES } from "../constants/cssClasses"; +import { RESULT_TYPE_META, ResultTone } from "../constants/resultTypeMeta"; +import type { + CurrentAddress, + NormalizedSmartyAddressConfig, + VerificationBehavior, + VerificationResult, +} from "../interfaces"; + +interface UiConfig { + ui: NonNullable["ui"]; +} + +const TONE_CLASS: Record = { + positive: CSS_CLASSES.verifyBadgePositive, + warning: CSS_CLASSES.verifyBadgeWarning, + negative: CSS_CLASSES.verifyBadgeNegative, +}; + +// Optional UI surfaces for verification (ERD §8). Follows DropdownService's +// DOM-creation + announce() aria-live pattern; themed entirely via CSS +// variables. Each surface is swappable without touching classification. +export class VerificationUiService extends BaseService { + private streetSelector: string | null = null; + private themeClasses: string[] = []; + private announcer: HTMLElement | null = null; + private surface: HTMLElement | null = null; + private chooser: HTMLElement | null = null; + + init(config: NormalizedSmartyAddressConfig) { + this.streetSelector = config.streetSelector ?? null; + this.themeClasses = config.theme ?? []; + } + + // Verify surfaces sit outside the dropdown wrapper, so the configured theme + // classes are carried on each surface directly — themes restyle verification + // the same way they restyle the dropdown (ERD §8.2). The verify_default + // class supplies fallback values for every variable. + private surfaceClasses(...classes: string[]): string[] { + return [CSS_CLASSES.verifyVars, ...this.themeClasses, ...classes]; + } + + destroy() { + this.clear(); + this.announcer?.remove(); + this.announcer = null; + } + + render(result: VerificationResult, _behavior: VerificationBehavior, config: UiConfig): void { + const surface = config.ui ?? "badge"; + const meta = RESULT_TYPE_META[result.type]; + const message = this.buildMessage(result); + + // Type 8 (error) is always aria-only / silent regardless of `ui` (ERD §8.1). + if (result.type === "error") { + this.clear(); + if (surface !== "none") this.announce(message); + return; + } + + if (surface === "none") return; + + this.announce(message); + if (surface === "aria-only") { + this.clear(); + return; + } + + if (surface === "panel") { + this.renderPanel(message, meta.tone); + return; + } + + this.renderBadge(meta.badge, meta.tone); + } + + // Ambiguous (Type 6) chooser — a lightweight candidate picker that works with + // no dropdown infrastructure (the verification-only fallback, Q4 / ERD §8.1). + renderChooser( + result: VerificationResult, + config: UiConfig, + onChoose: (chosen: CurrentAddress) => void, + ): void { + const surface = config.ui ?? "badge"; + if (surface === "none") return; + + this.announce(RESULT_TYPE_META.ambiguous.message); + if (surface === "aria-only") { + this.clear(); + return; + } + + this.clear(); + const candidates = result.candidates ?? []; + const anchor = this.getAnchor(); + if (!anchor || candidates.length === 0) return; + + const domService = this.getService("domService"); + const chooser = domService.createDomElement( + "div", + this.surfaceClasses(CSS_CLASSES.verifyPanel, CSS_CLASSES.verifyChooser), + ); + chooser.setAttribute("role", "listbox"); + + const heading = domService.createDomElement("div", [CSS_CLASSES.verifyPanelMessage]); + heading.textContent = RESULT_TYPE_META.ambiguous.message; + chooser.appendChild(heading); + + candidates.forEach((candidate) => { + const option = domService.createDomElement("button", [CSS_CLASSES.verifyChooserOption]); + option.setAttribute("type", "button"); + option.setAttribute("role", "option"); + option.textContent = this.formatAddress(candidate); + option.addEventListener("click", () => { + onChoose(candidate); + this.clear(); + }); + chooser.appendChild(option); + }); + + anchor.insertAdjacentElement("afterend", chooser); + this.chooser = chooser; + } + + clear(): void { + this.surface?.remove(); + this.surface = null; + this.chooser?.remove(); + this.chooser = null; + } + + private buildMessage(result: VerificationResult): string { + const base = RESULT_TYPE_META[result.type].message; + const showsCorrection = result.type === "corrected" && result.corrected; + if (showsCorrection) return `${base} ${this.formatAddress(result.corrected!)}`; + return base; + } + + protected formatAddress(address: CurrentAddress): string { + const line1 = [address.street, address.secondary].filter(Boolean).join(", "); + const line2 = [address.locality, address.administrativeArea].filter(Boolean).join(", "); + return [line1, `${line2} ${address.postalCode}`.trim()].filter(Boolean).join(" · "); + } + + private renderBadge(label: string, tone: ResultTone): void { + this.clear(); + const anchor = this.getAnchor(); + if (!anchor) return; + + const domService = this.getService("domService"); + const badge = domService.createDomElement( + "span", + this.surfaceClasses(CSS_CLASSES.verifyBadge, TONE_CLASS[tone]), + ); + badge.setAttribute("role", "status"); + badge.textContent = label; + anchor.insertAdjacentElement("afterend", badge); + this.surface = badge; + } + + // Full inline panel (R2 / Epic 2): shows the diff note, secondary prompt, or + // caution text. Tone-styled; copy comes from the result message. + private renderPanel(message: string, tone: ResultTone): void { + this.clear(); + const anchor = this.getAnchor(); + if (!anchor) return; + + const domService = this.getService("domService"); + const panel = domService.createDomElement( + "div", + this.surfaceClasses(CSS_CLASSES.verifyPanel, TONE_CLASS[tone]), + ); + panel.setAttribute("role", "status"); + const text = domService.createDomElement("div", [CSS_CLASSES.verifyPanelMessage]); + text.textContent = message; + panel.appendChild(text); + anchor.insertAdjacentElement("afterend", panel); + this.surface = panel; + } + + protected getAnchor(): HTMLElement | null { + return this.getService("domService").findDomElement(this.streetSelector); + } + + announce(message: string): void { + const region = this.ensureAnnouncer(); + if (region) region.textContent = message; + } + + private ensureAnnouncer(): HTMLElement | null { + if (this.announcer) return this.announcer; + if (typeof document === "undefined") return null; + + const announcer = this.getService("domService").createDomElement("div", [CSS_CLASSES.srOnly]); + announcer.classList.add(CSS_CLASSES.verifyAnnouncer); + announcer.setAttribute("aria-live", "polite"); + announcer.setAttribute("role", "status"); + document.body.appendChild(announcer); + this.announcer = announcer; + return announcer; + } +} diff --git a/src/services/http/streetTransport.ts b/src/services/http/streetTransport.ts new file mode 100644 index 0000000..b2d68d9 --- /dev/null +++ b/src/services/http/streetTransport.ts @@ -0,0 +1,68 @@ +import { USER_AGENT } from "../ApiService"; +import type { VerificationError } from "../../interfaces"; + +// Shared auth/transport for the Street APIs (ERD §1.1). A free function both +// VerificationService branches call rather than a base class — neither +// ApiService nor VerificationService is the other's parent. The embedded key is +// passed in per call from each service's init()-stored key. + +export type StreetErrorKind = VerificationError["kind"]; + +export class StreetApiError extends Error { + readonly kind: StreetErrorKind; + readonly status: number | undefined; + readonly cause: unknown; + + constructor(kind: StreetErrorKind, message: string, status?: number, cause?: unknown) { + super(message); + this.name = "StreetApiError"; + this.kind = kind; + this.status = status; + this.cause = cause; + } +} + +const errorKindForStatus = (status: number): StreetErrorKind => { + if (status === 401 || status === 402) return "auth"; + if (status === 429) return "quota"; + return "unknown"; +}; + +export async function fetchStreetJson( + baseUrl: string, + params: Record, + fetchFn: typeof fetch = fetch, +): Promise { + const query = new URLSearchParams({ "user-agent": USER_AGENT, ...params }); + + let response: Response; + try { + response = await fetchFn(`${baseUrl}?${query}`); + } catch (cause) { + throw new StreetApiError( + "network", + "Network request to the Smarty Street API failed", + undefined, + cause, + ); + } + + if (!response.ok) { + throw new StreetApiError( + errorKindForStatus(response.status), + `Smarty Street API responded with status ${response.status}`, + response.status, + ); + } + + try { + return (await response.json()) as T; + } catch (cause) { + throw new StreetApiError( + "parse", + "Could not parse the Smarty Street API response", + response.status, + cause, + ); + } +} diff --git a/src/services/verificationEpic2.test.ts b/src/services/verificationEpic2.test.ts new file mode 100644 index 0000000..65b051a --- /dev/null +++ b/src/services/verificationEpic2.test.ts @@ -0,0 +1,179 @@ +/** + * @jest-environment jsdom + */ +import { VerificationService } from "./VerificationService"; +import { VerificationUiService } from "./VerificationUiService"; +import { FormService } from "./FormService"; +import { DomService } from "./DomService"; +import { CSS_CLASSES } from "../constants/cssClasses"; +import type { VerificationConfig } from "../interfaces"; + +const candidate = (analysis: Record, components: Record = {}) => ({ + delivery_line_1: "120 W Center St", + last_line: "Provo UT 84601", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84601", ...components }, + analysis, +}); + +const ambiguousCandidates = [ + candidate({ dpv_match_code: "Y", footnotes: "" }, { plus4_code: "4402" }), + { + ...candidate({ dpv_match_code: "Y", footnotes: "" }, { plus4_code: "3108" }), + delivery_line_1: "120 E Center St", + }, +]; + +const correctedCandidate = candidate( + { dpv_match_code: "Y", footnotes: "A#N#" }, + { plus4_code: "4402" }, +); + +const FORM_HTML = ` +
+ + + + +
`; + +const okFetch = (data: unknown): typeof fetch => + (async () => ({ ok: true, status: 200, json: async () => data })) as unknown as typeof fetch; + +function setup(verification: VerificationConfig = {}) { + document.body.innerHTML = FORM_HTML; + const domService = new DomService(); + const formService = new FormService(); + const verificationUiService = new VerificationUiService(); + const verificationService = new VerificationService(); + const services = { domService, formService, verificationUiService, verificationService }; + Object.values(services).forEach((service) => service.setServices(services)); + + const config = { + embeddedKey: "key", + streetSelector: "#street", + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + autocompleteApiUrl: "", + internationalAutocompleteApiUrl: "", + theme: [], + verification, + } as never; + + formService.init(config); + verificationUiService.init(config); + verificationService.init(config); + return { verificationService }; +} + +describe("Epic 2 — panel surface", () => { + it("renders a full panel with the correction message", async () => { + const { verificationService } = setup({ ui: "panel" }); + verificationService.setFetch(okFetch([correctedCandidate])); + await verificationService.verifyCurrent("manual"); + + const panel = document.querySelector(`.${CSS_CLASSES.verifyPanel}`); + expect(panel).not.toBeNull(); + expect(panel?.querySelector(`.${CSS_CLASSES.verifyPanelMessage}`)?.textContent).toContain( + "Adjusted", + ); + }); +}); + +describe("Epic 2 — ambiguous chooser (Type 6)", () => { + it("renders a chooser listing every candidate", async () => { + const { verificationService } = setup(); + verificationService.setFetch(okFetch(ambiguousCandidates)); + const result = await verificationService.verifyCurrent("manual"); + + expect(result?.type).toBe("ambiguous"); + const options = document.querySelectorAll(`.${CSS_CLASSES.verifyChooserOption}`); + expect(options).toHaveLength(2); + }); + + it("applies the picked candidate to the form and dismisses the chooser", async () => { + const { verificationService } = setup(); + verificationService.setFetch(okFetch(ambiguousCandidates)); + await verificationService.verifyCurrent("manual"); + + const options = document.querySelectorAll( + `.${CSS_CLASSES.verifyChooserOption}`, + ); + options[1].click(); + + expect((document.querySelector("#street") as HTMLInputElement).value).toBe("120 E Center St"); + expect(document.querySelector(`.${CSS_CLASSES.verifyChooser}`)).toBeNull(); + }); + + it("first-candidate override auto-applies candidate[0] with no chooser", async () => { + const { verificationService } = setup({ onResult: { ambiguous: "first-candidate" } }); + verificationService.setFetch(okFetch(ambiguousCandidates)); + await verificationService.verifyCurrent("manual"); + + expect((document.querySelector("#street") as HTMLInputElement).value).toBe("120 W Center St"); + expect(document.querySelector(`.${CSS_CLASSES.verifyChooserOption}`)).toBeNull(); + }); + + it("first-candidate presents the auto-pick as a correction, not as 'choose one'", async () => { + const { verificationService } = setup({ onResult: { ambiguous: "first-candidate" } }); + verificationService.setFetch(okFetch(ambiguousCandidates)); + await verificationService.verifyCurrent("manual"); + + const badge = document.querySelector(`.${CSS_CLASSES.verifyBadge}`); + expect(badge?.textContent).toBe("Adjusted"); + }); + + it("ignore override records the result and renders nothing", async () => { + const { verificationService } = setup({ onResult: { ambiguous: "ignore" } }); + verificationService.setFetch(okFetch(ambiguousCandidates)); + const result = await verificationService.verifyCurrent("manual"); + + expect(result?.type).toBe("ambiguous"); + expect(document.querySelector(`.${CSS_CLASSES.verifyChooser}`)).toBeNull(); + expect(document.querySelector(`.${CSS_CLASSES.verifyBadge}`)).toBeNull(); + expect(document.querySelector(`.${CSS_CLASSES.verifyPanel}`)).toBeNull(); + }); + + it("a chooser pick is fingerprint-recorded so the follow-up blur is deduped", async () => { + const fetchFn = jest.fn(okFetch(ambiguousCandidates)); + const { verificationService } = setup(); + verificationService.setFetch(fetchFn as unknown as typeof fetch); + await verificationService.verifyCurrent("manual"); + + document.querySelectorAll(`.${CSS_CLASSES.verifyChooserOption}`)[1].click(); + await verificationService.verifyCurrent("blur"); + + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("aria-only renders no visible chooser, only the announcement", async () => { + const { verificationService } = setup({ ui: "aria-only" }); + verificationService.setFetch(okFetch(ambiguousCandidates)); + await verificationService.verifyCurrent("manual"); + + expect(document.querySelector(`.${CSS_CLASSES.verifyChooser}`)).toBeNull(); + expect(document.querySelector(`.${CSS_CLASSES.verifyAnnouncer}`)?.textContent).toContain( + "More than one address", + ); + }); + + it("a customer onCorrectionOffered decision overrides the built-in chooser", async () => { + const chosen = { + street: "120 N Center St", + secondary: "", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84601-2877", + country: "USA", + origin: "verification" as const, + }; + const onCorrectionOffered = jest.fn().mockResolvedValue({ action: "choose", chosen }); + const { verificationService } = setup({ onCorrectionOffered }); + verificationService.setFetch(okFetch(ambiguousCandidates)); + await verificationService.verifyCurrent("manual"); + + expect(onCorrectionOffered).toHaveBeenCalled(); + expect((document.querySelector("#street") as HTMLInputElement).value).toBe("120 N Center St"); + expect(document.querySelector(`.${CSS_CLASSES.verifyChooserOption}`)).toBeNull(); + }); +}); diff --git a/src/services/verificationEpic3.test.ts b/src/services/verificationEpic3.test.ts new file mode 100644 index 0000000..80c6c6f --- /dev/null +++ b/src/services/verificationEpic3.test.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment jsdom + */ +import SmartyAddress from "../index"; +import type { SmartyAddressConfig } from "../interfaces"; + +const candidate = (analysis: Record, components: Record = {}) => ({ + delivery_line_1: "3214 N University Ave", + last_line: "Provo UT 84604", + components: { city_name: "Provo", state_abbreviation: "UT", zipcode: "84604", ...components }, + analysis, +}); + +const verifiedCandidate = candidate({ dpv_match_code: "Y", footnotes: "" }); +const undeliverableCandidate = candidate({ dpv_match_code: "N", footnotes: "" }); + +const okFetch = (data: unknown): typeof fetch => + (async () => ({ ok: true, status: 200, json: async () => data })) as unknown as typeof fetch; + +const failFetch = (status: number): typeof fetch => + (async () => ({ ok: false, status, json: async () => ({}) })) as unknown as typeof fetch; + +const FORM_HTML = ` +
+ + + + +
`; + +async function build( + verification: SmartyAddressConfig["verification"], + fetchData: { fetchFn: typeof fetch }, +) { + document.body.innerHTML = FORM_HTML; + const instance = await SmartyAddress.create({ + embeddedKey: "key", + streetSelector: "#street", + localitySelector: "#city", + administrativeAreaSelector: "#state", + postalCodeSelector: "#zip", + autocomplete: { enabled: false }, + verification, + _testMode: true, + } as SmartyAddressConfig); + // Inject fetch into the internal service via the public verify path. + ( + instance as unknown as { verificationService: { setFetch: (f: typeof fetch) => void } } + ).verificationService.setFetch(fetchData.fetchFn); + return instance; +} + +describe("Epic 3 — verifyBeforeSubmit()", () => { + it("allows submit for a verified address", async () => { + const instance = await build( + { trigger: ["manual"] }, + { fetchFn: okFetch([verifiedCandidate]) }, + ); + await expect(instance.verifyBeforeSubmit()).resolves.toBe(true); + instance.destroy(); + }); + + it("blocks submit when undeliverable is configured to block (Type 7 override)", async () => { + const instance = await build( + { trigger: ["manual"], onResult: { undeliverable: "block" } }, + { fetchFn: okFetch([undeliverableCandidate]) }, + ); + await expect(instance.verifyBeforeSubmit()).resolves.toBe(false); + instance.destroy(); + }); + + it("allows submit for undeliverable under the default warn (fail-open)", async () => { + const instance = await build( + { trigger: ["manual"] }, + { fetchFn: okFetch([undeliverableCandidate]) }, + ); + await expect(instance.verifyBeforeSubmit()).resolves.toBe(true); + instance.destroy(); + }); + + it("blocks submit on a service error when fail-closed", async () => { + const instance = await build( + { trigger: ["manual"], failureMode: "fail-closed" }, + { fetchFn: failFetch(500) }, + ); + await expect(instance.verifyBeforeSubmit()).resolves.toBe(false); + instance.destroy(); + }); + + it("lets an onBeforeSubmit hook force a block", async () => { + const onBeforeSubmit = jest.fn().mockReturnValue(false); + const instance = await build( + { trigger: ["manual"], onBeforeSubmit }, + { fetchFn: okFetch([verifiedCandidate]) }, + ); + await expect(instance.verifyBeforeSubmit()).resolves.toBe(false); + expect(onBeforeSubmit).toHaveBeenCalled(); + instance.destroy(); + }); +}); diff --git a/src/services/verificationEpic4.test.ts b/src/services/verificationEpic4.test.ts new file mode 100644 index 0000000..1922c1a --- /dev/null +++ b/src/services/verificationEpic4.test.ts @@ -0,0 +1,274 @@ +/** + * @jest-environment jsdom + */ +import { VerificationService } from "./VerificationService"; +import { VerificationUiService } from "./VerificationUiService"; +import { FormService } from "./FormService"; +import { DomService } from "./DomService"; +import type { CurrentAddress, VerificationConfig } from "../interfaces"; + +const enteredIntl = (overrides: Partial = {}): CurrentAddress => ({ + street: "221B Baker St", + secondary: "", + locality: "London", + administrativeArea: "", + postalCode: "NW1 6XE", + country: "GBR", + origin: "free-form", + ...overrides, +}); + +const intlCandidate = ( + analysis: Record, + components: Record = {}, +) => ({ + address1: "221B Baker St", + components: { + locality: "London", + postal_code: "NW1 6XE", + country_iso3: "GBR", + ...components, + }, + analysis, +}); + +const svc = new VerificationService(); +const classify = (candidates: unknown[]) => svc.classify(candidates, enteredIntl()); + +describe("Epic 4 — classifyInternational (ERD §5.4)", () => { + it("Type 1 — verified at country max precision", () => { + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }), + ]); + expect(result.type).toBe("verified"); + }); + + it("Type 2 — corrected (Verified-SmallChange)", () => { + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: { thoroughfare: "Verified-SmallChange" }, + }), + ]); + expect(result.type).toBe("corrected"); + }); + + it("Type 3 — missing secondary (Partial / Premise / no sub_building)", () => { + const result = classify([ + intlCandidate({ + verification_status: "Partial", + address_precision: "Premise", + max_address_precision: "DeliveryPoint", + changes: {}, + }), + ]); + expect(result.type).toBe("missingSecondary"); + }); + + it("Type 4 — secondary not recognized (changes.sub_building Unrecognized)", () => { + const result = classify([ + intlCandidate( + { + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: { sub_building: "Unrecognized" }, + }, + { sub_building: "Flat 99" }, + ), + ]); + expect(result.type).toBe("secondaryNotMatched"); + }); + + it("Type 6 — ambiguous (verification_status Ambiguous)", () => { + const result = classify([ + intlCandidate({ verification_status: "Ambiguous", address_precision: "Thoroughfare" }), + ]); + expect(result.type).toBe("ambiguous"); + }); + + it("Type 7 — undeliverable (status None)", () => { + expect(classify([intlCandidate({ verification_status: "None" })]).type).toBe("undeliverable"); + }); + + it("Type 7 — undeliverable (address_precision None)", () => { + expect( + classify([intlCandidate({ verification_status: "Partial", address_precision: "None" })]).type, + ).toBe("undeliverable"); + }); + + it("Type 7 — undeliverable (zero candidates)", () => { + expect(classify([]).type).toBe("undeliverable"); + }); + + it("Type 6 requires verification_status Ambiguous — multiple candidates alone are not ambiguous internationally", () => { + const verified = intlCandidate({ + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }); + const result = classify([verified, verified]); + expect(result.type).toBe("verified"); + }); + + it("guard order: Ambiguous + sub_building Unrecognized is ambiguous, not secondaryNotMatched", () => { + const result = classify([ + intlCandidate({ + verification_status: "Ambiguous", + address_precision: "Premise", + changes: { sub_building: "Unrecognized" }, + }), + ]); + expect(result.type).toBe("ambiguous"); + }); + + it("Type 2 requires a qualifying change — a mere component diff stays verified at country max", () => { + const result = classify([ + intlCandidate( + { + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }, + { locality: "Camden" }, + ), + ]); + expect(result.type).toBe("verified"); + }); + + it("a diff cannot bypass the Q10 precision gate — Verified below country max is not corrected", () => { + const result = classify([ + intlCandidate( + { + verification_status: "Verified", + address_precision: "Premise", + max_address_precision: "DeliveryPoint", + changes: {}, + }, + { locality: "Camden" }, + ), + ]); + expect(result.type).toBe("missingSecondary"); + }); + + it("Q10 fallback: missing max_address_precision below Premise is not verified, and the gap is logged", () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "Locality", + changes: {}, + }), + ]); + expect(result.type).not.toBe("verified"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("max_address_precision")); + warn.mockRestore(); + }); + + it("Type 5 (flagged) can never fire internationally", () => { + const statuses = ["Verified", "Partial", "Ambiguous", "None"]; + statuses.forEach((verification_status) => { + const result = classify([ + intlCandidate({ verification_status, address_precision: "Premise", dpv_vacant: "Y" }), + ]); + expect(result.type).not.toBe("flagged"); + }); + }); + + describe("Q10 — per-country max precision boundary", () => { + it("verified when precision reaches a lower country max (Locality)", () => { + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "Locality", + max_address_precision: "Locality", + changes: {}, + }), + ]); + expect(result.type).toBe("verified"); + }); + + it("not verified when precision is below the country max", () => { + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "Thoroughfare", + max_address_precision: "DeliveryPoint", + changes: {}, + }), + ]); + expect(result.type).not.toBe("verified"); + }); + + it("falls back to Premise minimum when max_address_precision is absent", () => { + const result = classify([ + intlCandidate({ + verification_status: "Verified", + address_precision: "Premise", + changes: {}, + }), + ]); + expect(result.type).toBe("verified"); + }); + }); +}); + +const okFetch = (data: unknown): typeof fetch => + (async () => ({ ok: true, status: 200, json: async () => data })) as unknown as typeof fetch; + +function setupIntl(verification: VerificationConfig = {}) { + document.body.innerHTML = ` + + + `; + const domService = new DomService(); + const formService = new FormService(); + const verificationUiService = new VerificationUiService(); + const verificationService = new VerificationService(); + const services = { domService, formService, verificationUiService, verificationService }; + Object.values(services).forEach((service) => service.setServices(services)); + const config = { + embeddedKey: "key", + streetSelector: "#street", + localitySelector: "#city", + postalCodeSelector: "#zip", + country: "GBR", + autocompleteApiUrl: "", + internationalAutocompleteApiUrl: "", + theme: [], + verification, + } as never; + formService.init(config); + verificationUiService.init(config); + verificationService.init(config); + return { verificationService }; +} + +describe("Epic 4 — international verify end-to-end", () => { + it("fetches the international Street API and renders a verified badge", async () => { + const { verificationService } = setupIntl(); + verificationService.setFetch( + okFetch([ + intlCandidate({ + verification_status: "Verified", + address_precision: "DeliveryPoint", + max_address_precision: "DeliveryPoint", + changes: {}, + }), + ]), + ); + const result = await verificationService.verifyCurrent("manual"); + expect(result?.source).toBe("international"); + expect(result?.type).toBe("verified"); + expect(document.querySelector(".smartyAddress__verifyBadge")?.textContent).toBe("Verified"); + }); +}); diff --git a/src/utils/appUtils.ts b/src/utils/appUtils.ts index aeb251c..2a6cd5c 100644 --- a/src/utils/appUtils.ts +++ b/src/utils/appUtils.ts @@ -4,7 +4,12 @@ import { colorStyles } from "../../assets/styles/colors"; import { miscStyles } from "../../assets/styles/misc"; import { spacingStyles } from "../../assets/styles/spacing"; import { StyleService } from "../services/StyleService"; -import { NormalizedSmartyAddressConfig } from "../interfaces"; +import { + NormalizedSmartyAddressConfig, + VerificationBehavior, + VerificationResultKey, +} from "../interfaces"; +import { ALLOWED_RESULT_BEHAVIORS } from "../constants"; export class ConfigValidationError extends Error { constructor(message: string) { @@ -13,7 +18,31 @@ export class ConfigValidationError extends Error { } } +export const isAutocompleteEnabled = (config: NormalizedSmartyAddressConfig): boolean => + config.autocomplete?.enabled ?? true; + +export const isVerificationEnabled = (config: NormalizedSmartyAddressConfig): boolean => + config.verification?.enabled ?? true; + +// Behaviors / surfaces not yet shipped in the current release. Each list shrinks +// as the corresponding epic lands (ERD §3.1 validation guard). Verification +// config that requests an unbuilt capability gets a clear warning rather than a +// silent no-op. +const UNSUPPORTED_BEHAVIORS: string[] = []; // block shipped in Epic 3 +const UNSUPPORTED_UI: string[] = []; // panel shipped in Epic 2 +const UNSUPPORTED_RESULT_KEYS: string[] = []; // ambiguous shipped in Epic 2 + export const validateConfig = (config: NormalizedSmartyAddressConfig): void => { + const autocompleteOn = isAutocompleteEnabled(config); + const verificationOn = isVerificationEnabled(config); + + if (!autocompleteOn && !verificationOn) { + console.warn( + "SmartyAddress: neither autocomplete nor verification is enabled; the plugin will not initialize.", + ); + return; + } + const errors: string[] = []; const isEmbeddedKeyMissing = @@ -37,6 +66,49 @@ export const validateConfig = (config: NormalizedSmartyAddressConfig): void => { if (errors.length > 0) { throw new ConfigValidationError(`SmartyAddress configuration error:\n- ${errors.join("\n- ")}`); } + + if (verificationOn) warnUnsupportedVerification(config.verification); +}; + +const warnUnsupportedVerification = ( + verification: NormalizedSmartyAddressConfig["verification"], +): void => { + if (!verification) return; + + const warn = (message: string) => + console.warn( + `SmartyAddress: ${message} is not yet supported in this version and will be ignored.`, + ); + + if (verification.ui && UNSUPPORTED_UI.includes(verification.ui)) { + warn(`verification.ui "${verification.ui}"`); + } + + const onResult = verification.onResult ?? {}; + for (const [resultKey, behavior] of Object.entries(onResult)) { + if (UNSUPPORTED_RESULT_KEYS.includes(resultKey)) { + warn(`verification.onResult.${resultKey}`); + } + if (behavior && UNSUPPORTED_BEHAVIORS.includes(behavior)) { + warn(`verification behavior "${behavior}"`); + } + warnDisallowedOverride(resultKey, behavior); + } +}; + +// ERD §3.1's allowed-overrides column is the canonical contract; an override +// outside it is dropped by the dispatcher, so say so instead of silently +// accepting config that does nothing (or worse — `verified: "block"`). +const warnDisallowedOverride = (resultKey: string, behavior: string | undefined): void => { + const allowed = ALLOWED_RESULT_BEHAVIORS[resultKey as VerificationResultKey]; + if (!behavior || !allowed || allowed.includes(behavior as VerificationBehavior)) return; + const reason = + resultKey === "error" + ? "type 8 is governed by verification.failureMode" + : `allowed values: ${allowed.join(", ")}`; + console.warn( + `SmartyAddress: verification.onResult.${resultKey}: "${behavior}" is not an allowed override (${reason}) and will be ignored.`, + ); }; export const defineStyles = () => { diff --git a/src/utils/configNormalizer.ts b/src/utils/configNormalizer.ts index b716237..09115aa 100644 --- a/src/utils/configNormalizer.ts +++ b/src/utils/configNormalizer.ts @@ -4,12 +4,23 @@ import { SELECTOR_ALIASES, API_FILTER_ALIASES } from "../constants/configAliases export function normalizeConfig(config: SmartyAddressConfig): NormalizedSmartyAddressConfig { const normalized: Record = {}; - for (const [key, value] of Object.entries(config)) { - if (value === undefined) continue; + // Fold a source's keys into the canonical root surface. Existing root keys + // win over the nested `autocomplete` block (root-key aliases, ERD §2 / Q5); + // `enabled` is a block-local flag, never a root key. + const fold = (source: Record) => { + for (const [key, value] of Object.entries(source)) { + if (value === undefined) continue; + if (key === "enabled") continue; + const canonicalKey = SELECTOR_ALIASES[key] ?? API_FILTER_ALIASES[key] ?? key; + normalized[canonicalKey] ??= value; + } + }; - const canonicalKey = SELECTOR_ALIASES[key] ?? API_FILTER_ALIASES[key] ?? key; - normalized[canonicalKey] ??= value; - } + fold(config as unknown as Record); + if (config.autocomplete) fold(config.autocomplete as unknown as Record); + + if (config.autocomplete !== undefined) normalized.autocomplete = config.autocomplete; + if (config.verification !== undefined) normalized.verification = config.verification; return normalized as unknown as NormalizedSmartyAddressConfig; } diff --git a/src/utils/currentAddress.test.ts b/src/utils/currentAddress.test.ts new file mode 100644 index 0000000..fb06d6b --- /dev/null +++ b/src/utils/currentAddress.test.ts @@ -0,0 +1,129 @@ +import { + computeDiff, + fingerprint, + fromSuggestion, + fromVerification, + toSuggestion, +} from "./currentAddress"; +import type { AutocompleteSuggestion, CurrentAddress, VerificationResult } from "../interfaces"; + +const address = (overrides: Partial = {}): CurrentAddress => ({ + street: "3214 N University Ave", + secondary: "", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84604", + country: "USA", + origin: "free-form", + ...overrides, +}); + +describe("currentAddress", () => { + describe("fromSuggestion", () => { + it("maps autocomplete fields and origin", () => { + const suggestion: AutocompleteSuggestion = { + street_line: "1 Main St", + secondary: "Apt 2", + locality: "Denver", + administrativeArea: "CO", + postalCode: "80202", + country: "USA", + address_id: "abc", + }; + expect(fromSuggestion(suggestion)).toEqual({ + street: "1 Main St", + secondary: "Apt 2", + locality: "Denver", + administrativeArea: "CO", + postalCode: "80202", + country: "USA", + origin: "autocomplete", + address_id: "abc", + }); + }); + + it("omits address_id when absent", () => { + const result = fromSuggestion({ + street_line: "1 Main St", + locality: "Denver", + administrativeArea: "CO", + postalCode: "80202", + country: "USA", + }); + expect(result.address_id).toBeUndefined(); + }); + }); + + describe("fromVerification", () => { + it("prefers the corrected address and re-origins to verification", () => { + const result = { + entered: address(), + corrected: address({ postalCode: "84604-4405", origin: "verification" }), + } as VerificationResult; + expect(fromVerification(result)).toMatchObject({ + postalCode: "84604-4405", + origin: "verification", + }); + }); + + it("falls back to entered when there is no correction", () => { + const result = { entered: address(), corrected: null } as VerificationResult; + expect(fromVerification(result)).toMatchObject({ + postalCode: "84604", + origin: "verification", + }); + }); + }); + + describe("toSuggestion round-trip", () => { + it("preserves the six address fields", () => { + const suggestion = toSuggestion(address({ secondary: "Apt 9", address_id: "x" })); + expect(suggestion).toMatchObject({ + street_line: "3214 N University Ave", + secondary: "Apt 9", + locality: "Provo", + administrativeArea: "UT", + postalCode: "84604", + country: "USA", + address_id: "x", + }); + }); + }); + + describe("fingerprint", () => { + it("is case- and whitespace-insensitive", () => { + const a = address({ street: "3214 N University Ave " }); + const b = address({ street: "3214 n university ave" }); + expect(fingerprint(a)).toBe(fingerprint(b)); + }); + + it("differs when a field changes", () => { + expect(fingerprint(address())).not.toBe(fingerprint(address({ postalCode: "84604-4405" }))); + }); + + it("ignores origin / address_id", () => { + expect(fingerprint(address({ origin: "autocomplete" }))).toBe( + fingerprint(address({ origin: "verification", address_id: "z" })), + ); + }); + }); + + describe("computeDiff", () => { + it("returns null when nothing changed", () => { + expect(computeDiff(address(), address())).toBeNull(); + }); + + it("reports only changed fields", () => { + const diff = computeDiff( + address(), + address({ postalCode: "84604-4405", street: "3214 North University Avenue" }), + ); + expect(diff?.changedFields.sort()).toEqual(["postalCode", "street"]); + expect(diff?.changes.postalCode).toEqual({ from: "84604", to: "84604-4405" }); + }); + + it("does not treat country as a correction", () => { + expect(computeDiff(address(), address({ country: "US" }))).toBeNull(); + }); + }); +}); diff --git a/src/utils/currentAddress.ts b/src/utils/currentAddress.ts new file mode 100644 index 0000000..83f8116 --- /dev/null +++ b/src/utils/currentAddress.ts @@ -0,0 +1,89 @@ +import type { + AddressDiff, + AddressField, + AutocompleteSuggestion, + CurrentAddress, + VerificationResult, +} from "../interfaces"; + +// Adapters + helpers for the CurrentAddress abstraction (ERD §4.1). Pure +// functions: the seam that lets verification run identically regardless of +// whether the address came from autocomplete, free-form fields, or a prior +// verification. + +const CORRECTABLE_FIELDS: AddressField[] = [ + "street", + "secondary", + "locality", + "administrativeArea", + "postalCode", +]; + +const FINGERPRINT_FIELDS: AddressField[] = [...CORRECTABLE_FIELDS, "country"]; + +export function fromSuggestion(suggestion: AutocompleteSuggestion): CurrentAddress { + const address: CurrentAddress = { + street: suggestion.street_line ?? "", + secondary: suggestion.secondary ?? "", + locality: suggestion.locality ?? "", + administrativeArea: suggestion.administrativeArea ?? "", + postalCode: suggestion.postalCode ?? "", + country: suggestion.country ?? "", + origin: "autocomplete", + }; + if (suggestion.address_id) address.address_id = suggestion.address_id; + return address; +} + +export function fromVerification(result: VerificationResult): CurrentAddress { + const base = result.corrected ?? result.entered; + return { ...base, origin: "verification" }; +} + +export function toSuggestion(address: CurrentAddress): AutocompleteSuggestion { + const suggestion: AutocompleteSuggestion = { + street_line: address.street, + secondary: address.secondary, + locality: address.locality, + administrativeArea: address.administrativeArea, + postalCode: address.postalCode, + country: address.country, + }; + if (address.address_id) suggestion.address_id = address.address_id; + return suggestion; +} + +// Normalized fingerprint for the in-memory dedupe + staleness check (ERD §5.6). +// No persistent cache; this is the only dedupe in v1. Field boundaries are +// deliberately collapsed: forms without a secondary (or with a single combined +// field) merge components into the street input, so "123 Main St, Apt 4" + "" +// must fingerprint the same as "123 Main St" + "Apt 4" or the documented +// selection→blur double-call comes back (RS Epic 1 exit criterion). +export function fingerprint(address: CurrentAddress): string { + return normalizeForFingerprint( + FINGERPRINT_FIELDS.map((field) => String(address[field] ?? "")).join(" "), + ); +} + +function normalizeForFingerprint(value: string): string { + return value.toLowerCase().replace(/[.,#]/g, " ").replace(/\s+/g, " ").trim(); +} + +export function computeDiff( + entered: CurrentAddress, + corrected: CurrentAddress, +): AddressDiff | null { + const changes: AddressDiff["changes"] = {}; + const changedFields: AddressField[] = []; + + for (const field of CORRECTABLE_FIELDS) { + const from = String(entered[field] ?? "").trim(); + const to = String(corrected[field] ?? "").trim(); + if (from === to) continue; + changes[field] = { from, to }; + changedFields.push(field); + } + + if (changedFields.length === 0) return null; + return { changes, changedFields }; +} diff --git a/src/utils/verificationConfig.test.ts b/src/utils/verificationConfig.test.ts new file mode 100644 index 0000000..4c55625 --- /dev/null +++ b/src/utils/verificationConfig.test.ts @@ -0,0 +1,120 @@ +import { normalizeConfig } from "./configNormalizer"; +import { validateConfig, isAutocompleteEnabled, isVerificationEnabled } from "./appUtils"; +import type { NormalizedSmartyAddressConfig, SmartyAddressConfig } from "../interfaces"; + +describe("normalizeConfig — de-skew (Q5)", () => { + it("folds a nested autocomplete block down to root keys", () => { + const normalized = normalizeConfig({ + embeddedKey: "k", + autocomplete: { streetSelector: "#street", citySelector: "#city" }, + } as SmartyAddressConfig); + expect(normalized.streetSelector).toBe("#street"); + expect(normalized.localitySelector).toBe("#city"); + }); + + it("keeps existing root keys winning over the nested block (additive aliases)", () => { + const normalized = normalizeConfig({ + embeddedKey: "k", + streetSelector: "#root-street", + autocomplete: { streetSelector: "#nested-street" }, + } as SmartyAddressConfig); + expect(normalized.streetSelector).toBe("#root-street"); + }); + + it("passes the verification block through untouched", () => { + const verification = { enabled: true, ui: "badge" as const }; + const normalized = normalizeConfig({ + embeddedKey: "k", + streetSelector: "#s", + verification, + } as SmartyAddressConfig); + expect(normalized.verification).toEqual(verification); + }); + + it("does not leak the block-local 'enabled' flag onto the root", () => { + const normalized = normalizeConfig({ + embeddedKey: "k", + streetSelector: "#s", + autocomplete: { enabled: false }, + } as SmartyAddressConfig) as Record; + expect(normalized.enabled).toBeUndefined(); + }); +}); + +describe("mode resolution", () => { + const base = { embeddedKey: "k", streetSelector: "#s" } as NormalizedSmartyAddressConfig; + + it("both modes default to enabled", () => { + expect(isAutocompleteEnabled(base)).toBe(true); + expect(isVerificationEnabled(base)).toBe(true); + }); + + it("respects explicit disable", () => { + expect(isAutocompleteEnabled({ ...base, autocomplete: { enabled: false } })).toBe(false); + expect(isVerificationEnabled({ ...base, verification: { enabled: false } })).toBe(false); + }); +}); + +describe("validateConfig", () => { + let warn: jest.SpyInstance; + beforeEach(() => { + warn = jest.spyOn(console, "warn").mockImplementation(() => undefined); + }); + afterEach(() => warn.mockRestore()); + + it("warns and no-ops when neither mode is enabled (does not throw)", () => { + expect(() => + validateConfig({ + embeddedKey: "", + autocomplete: { enabled: false }, + verification: { enabled: false }, + } as NormalizedSmartyAddressConfig), + ).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("will not initialize")); + }); + + it("still requires embeddedKey + streetSelector when a mode is on", () => { + expect(() => validateConfig({ embeddedKey: "" } as NormalizedSmartyAddressConfig)).toThrow( + /embeddedKey is required/, + ); + }); + + it("accepts panel UI, ambiguous override, and block behavior (Epics 2–3)", () => { + validateConfig({ + embeddedKey: "k", + streetSelector: "#s", + verification: { ui: "panel", onResult: { ambiguous: "prompt", undeliverable: "block" } }, + } as NormalizedSmartyAddressConfig); + const messages = warn.mock.calls.map((call) => String(call[0])); + expect(messages.some((m) => m.includes("not yet supported"))).toBe(false); + }); + + it("warns on an override outside the ERD §3.1 allowed list (e.g. verified: block)", () => { + validateConfig({ + embeddedKey: "k", + streetSelector: "#s", + verification: { onResult: { verified: "block" } }, + } as NormalizedSmartyAddressConfig); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('verification.onResult.verified: "block" is not an allowed override'), + ); + }); + + it("warns that type 8 takes no override (governed by failureMode)", () => { + validateConfig({ + embeddedKey: "k", + streetSelector: "#s", + verification: { onResult: { error: "silent" } }, + } as never); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("verification.failureMode")); + }); + + it("does not warn for allowed overrides", () => { + validateConfig({ + embeddedKey: "k", + streetSelector: "#s", + verification: { onResult: { corrected: "silent", secondaryNotMatched: "apply-primary" } }, + } as NormalizedSmartyAddressConfig); + expect(warn).not.toHaveBeenCalled(); + }); +});