@classytic/primitives 0.23.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/address.d.mts +270 -1
- package/dist/address.mjs +190 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.24.0] - 2026-08-24
|
|
7
|
+
|
|
8
|
+
### Added — `@classytic/primitives/address`: capture-format system
|
|
9
|
+
|
|
10
|
+
`AddressFormat`, `AddressFieldDescriptor`, `AddressFieldKey`, `PartialAddress`, `AddressFieldError` — a description of HOW a country collects a `ContactAddress`: field labels, order, requiredness, subdivision taxonomy hooks, and postal code pattern. Country packs (e.g. `@classytic/bd-areas/address-format`) live beside their taxonomies; this subpath owns only the generic shape.
|
|
11
|
+
|
|
12
|
+
`validateAddress(address, format)` — validates a partial address against a format; returns `AddressFieldError[]`. Enforces `required` on both canonical and extension fields identically, and checks `postalCodePattern` only when a value is given and a pattern is declared. `isAddressValid` and `describeAddressError` are convenience wrappers.
|
|
13
|
+
|
|
14
|
+
Every `key` in a descriptor is a field of `ContactAddress`; co-location with the value object is what makes a mismatch obvious instead of invisible.
|
|
15
|
+
|
|
6
16
|
## [0.23.0] - 2026-08-15
|
|
7
17
|
|
|
8
18
|
### Added — `./approval`: `notRequiredChain(reason)` + `isNotRequired(chain)`
|
package/dist/address.d.mts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Kept deliberately generic — international postal formats vary too much to
|
|
6
6
|
* enforce per-country rules in a shared type. Consumers can extend or narrow
|
|
7
7
|
* as needed (e.g. a BD-specific schema might add `district`, `upazila`).
|
|
8
|
+
*
|
|
9
|
+
* That still holds, and the CAPTURE FORMAT section below does not weaken it. A
|
|
10
|
+
* format constrains no type and adds no field: it is opt-in DATA describing how
|
|
11
|
+
* a country asks for the fields declared here. The type stays generic precisely
|
|
12
|
+
* so one canonical shape can be captured many ways.
|
|
8
13
|
*/
|
|
9
14
|
interface Address {
|
|
10
15
|
line1: string;
|
|
@@ -34,5 +39,269 @@ interface GeoJsonPoint {
|
|
|
34
39
|
}
|
|
35
40
|
declare function toGeoJsonPoint(p: GeoPoint): GeoJsonPoint;
|
|
36
41
|
declare function fromGeoJsonPoint(g: GeoJsonPoint): GeoPoint;
|
|
42
|
+
/**
|
|
43
|
+
* How a country's postal address is CAPTURED — labels, order, requiredness, and
|
|
44
|
+
* which fields come from a subdivision taxonomy rather than free text.
|
|
45
|
+
*
|
|
46
|
+
* ## Why this lives HERE, beside the value object
|
|
47
|
+
*
|
|
48
|
+
* Storage was already standardised: an address is `ContactAddress` above. What
|
|
49
|
+
* varied — and varied by being hardcoded — is CAPTURE. The checkout form asked
|
|
50
|
+
* for "Division", "District" and "Delivery Area" because the deployment is
|
|
51
|
+
* Bangladeshi, so selling in a second country meant editing the form. That is
|
|
52
|
+
* the defect `@classytic/ledger` already solved for charts of accounts: the
|
|
53
|
+
* ENGINE was generic and the BINDING was not.
|
|
54
|
+
*
|
|
55
|
+
* This first went into a separate `@classytic/address` package. Wrong, for the
|
|
56
|
+
* reason this codebase has just paid for twice: a description of a shape that
|
|
57
|
+
* lives away from the shape DRIFTS from it. `Order`'s Mongoose schema said
|
|
58
|
+
* `addressLine1` while the type it declared said `line1`, and nothing could see
|
|
59
|
+
* the contradiction because they were in different files. Every `key` below is
|
|
60
|
+
* a field of `ContactAddress`; putting them in the same file is what makes a
|
|
61
|
+
* mismatch obvious instead of invisible.
|
|
62
|
+
*
|
|
63
|
+
* The COUNTRY PACKS do not live here — they are localizations, and they follow
|
|
64
|
+
* the `ledger` / `ledger-bd` precedent: Bangladesh's is
|
|
65
|
+
* `@classytic/bd-areas/address-format`, beside the taxonomy it selects from.
|
|
66
|
+
* Nothing in this file knows what a division is.
|
|
67
|
+
*
|
|
68
|
+
* ## What a format does NOT do
|
|
69
|
+
*
|
|
70
|
+
* It does not introduce new stored fields. Every descriptor's `key` is a field
|
|
71
|
+
* of `ContactAddress`, so a format is a VIEW over one canonical shape:
|
|
72
|
+
* Bangladesh labels `state` "Division", Japan labels it "Prefecture", the United
|
|
73
|
+
* States labels it "State". Same column, same value object, different word.
|
|
74
|
+
*
|
|
75
|
+
* A country that genuinely needs an extra datum (a thana, an upazila, a landmark)
|
|
76
|
+
* puts it in `metadata` — primitives' own guidance — never in a new top-level
|
|
77
|
+
* field, because that would make the stored shape country-dependent again.
|
|
78
|
+
*/
|
|
79
|
+
/** The `ContactAddress` fields a format may lay out. */
|
|
80
|
+
type AddressFieldKey = 'name' | 'phone' | 'email' | 'company' | 'line1' | 'line2' | 'city' | 'state' | 'postalCode' | 'country';
|
|
81
|
+
/**
|
|
82
|
+
* How a field is entered.
|
|
83
|
+
*
|
|
84
|
+
* `subdivision` is the interesting one: the value is still a plain string on the
|
|
85
|
+
* stored address, but it is CHOSEN from a taxonomy rather than typed. Bangladesh
|
|
86
|
+
* picks a division then a district; India picks a state; the US picks a state.
|
|
87
|
+
* The taxonomy itself is supplied by the host through `AddressTaxonomyPort` —
|
|
88
|
+
* this package ships no place names, so it stays country-agnostic and small.
|
|
89
|
+
*/
|
|
90
|
+
type AddressFieldInput = 'text' | 'tel' | 'email' | 'subdivision';
|
|
91
|
+
interface AddressFieldSpec {
|
|
92
|
+
/** The canonical `ContactAddress` field this captures. */
|
|
93
|
+
key: AddressFieldKey;
|
|
94
|
+
/** Label in the deployment's language. */
|
|
95
|
+
label: string;
|
|
96
|
+
input: AddressFieldInput;
|
|
97
|
+
required?: boolean;
|
|
98
|
+
/** Spans the full row in a two-column form. */
|
|
99
|
+
wide?: boolean;
|
|
100
|
+
placeholder?: string;
|
|
101
|
+
/** Help text under the field. */
|
|
102
|
+
hint?: string;
|
|
103
|
+
/**
|
|
104
|
+
* For `input: 'subdivision'` — the taxonomy level this field selects, resolved
|
|
105
|
+
* through `AddressTaxonomyPort`. Opaque to this package: `'division'`,
|
|
106
|
+
* `'district'`, `'area'`, `'state'`, `'prefecture'` are all just strings.
|
|
107
|
+
*/
|
|
108
|
+
taxonomy?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The field whose value narrows this one's options — Bangladesh's district
|
|
111
|
+
* list depends on the chosen division. Must appear EARLIER in `fields`.
|
|
112
|
+
*/
|
|
113
|
+
dependsOn?: string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A field a country needs that `ContactAddress` does not model.
|
|
117
|
+
*
|
|
118
|
+
* ## Why this exists rather than growing the value object
|
|
119
|
+
*
|
|
120
|
+
* Bangladesh's checkout captures a **Delivery Area** — the locality couriers
|
|
121
|
+
* actually route and price on, finer than a district. It is required there and
|
|
122
|
+
* meaningless in most countries.
|
|
123
|
+
*
|
|
124
|
+
* The two obvious answers are both wrong. Adding `areaName` to `ContactAddress`
|
|
125
|
+
* puts one market's concept in the base type every country pays for; leaving it
|
|
126
|
+
* out entirely means the country that most needs a format cannot express its
|
|
127
|
+
* most important field, which is how the form ends up hardcoded again.
|
|
128
|
+
*
|
|
129
|
+
* So an extension field is DECLARED like any other — same labels, same
|
|
130
|
+
* requiredness, same taxonomy machinery — but its `name` is free-form and the
|
|
131
|
+
* consuming DOMAIN decides where the value lands. `@classytic/order` promotes
|
|
132
|
+
* `areaId` / `areaName` / `zoneId` to real columns because its carrier adapters
|
|
133
|
+
* read them; anything it does not recognise goes to `metadata`. Neither
|
|
134
|
+
* decision belongs in this package, and neither requires a release of it.
|
|
135
|
+
*
|
|
136
|
+
* Values live FLAT alongside the canonical ones, not in a nested bag — which is
|
|
137
|
+
* how `areaName` is already stored, and it means a form produces one object
|
|
138
|
+
* rather than two that can disagree.
|
|
139
|
+
*/
|
|
140
|
+
interface AddressExtensionFieldSpec {
|
|
141
|
+
/** Discriminant. */
|
|
142
|
+
extension: true;
|
|
143
|
+
/**
|
|
144
|
+
* Free-form key. Not validated against `ContactAddress` — that is the point.
|
|
145
|
+
* The domain maps it to a column or to `metadata`.
|
|
146
|
+
*/
|
|
147
|
+
name: string;
|
|
148
|
+
label: string;
|
|
149
|
+
input: AddressFieldInput;
|
|
150
|
+
required?: boolean;
|
|
151
|
+
wide?: boolean;
|
|
152
|
+
placeholder?: string;
|
|
153
|
+
hint?: string;
|
|
154
|
+
taxonomy?: string;
|
|
155
|
+
/** May name a canonical key OR another extension. Must appear EARLIER. */
|
|
156
|
+
dependsOn?: string;
|
|
157
|
+
}
|
|
158
|
+
type AnyAddressFieldSpec = AddressFieldSpec | AddressExtensionFieldSpec;
|
|
159
|
+
/** The key a spec writes to, canonical or extension. */
|
|
160
|
+
declare function fieldName(spec: AnyAddressFieldSpec): string;
|
|
161
|
+
/** Narrowing helper — `true` when the spec is an extension. */
|
|
162
|
+
declare function isExtensionField(spec: AnyAddressFieldSpec): spec is AddressExtensionFieldSpec;
|
|
163
|
+
interface AddressFormat {
|
|
164
|
+
/** ISO 3166-1 alpha-2, or `'*'` for the international fallback. */
|
|
165
|
+
country: string;
|
|
166
|
+
/** Display name of the country, for a read-only country field. */
|
|
167
|
+
countryLabel: string;
|
|
168
|
+
/** Ordered — this is the form layout. Canonical and extension fields interleave. */
|
|
169
|
+
fields: AnyAddressFieldSpec[];
|
|
170
|
+
/**
|
|
171
|
+
* Validate `postalCode` when present. Omit for countries where the code is
|
|
172
|
+
* free-form or absent; a wrong pattern is worse than none, because it refuses
|
|
173
|
+
* addresses that are perfectly valid.
|
|
174
|
+
*/
|
|
175
|
+
postalCodePattern?: RegExp;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A taxonomy level's options, narrowed by the parent selection.
|
|
179
|
+
*
|
|
180
|
+
* A HOST PORT, not data in this package: Bangladesh's divisions live in
|
|
181
|
+
* `@classytic/bd-areas`, and a kernel that imported them would be a
|
|
182
|
+
* country-agnostic package with one country's place names compiled into it.
|
|
183
|
+
*/
|
|
184
|
+
interface AddressTaxonomyPort {
|
|
185
|
+
/**
|
|
186
|
+
* @param level the `taxonomy` string from the field spec
|
|
187
|
+
* @param parentId the selected value of `dependsOn`, when the field has one
|
|
188
|
+
*/
|
|
189
|
+
options(level: string, parentId?: string): Array<{
|
|
190
|
+
id: string;
|
|
191
|
+
label: string;
|
|
192
|
+
hint?: string;
|
|
193
|
+
}>;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The generic international format — the FALLBACK, and the proof the contract is
|
|
197
|
+
* not BD-shaped.
|
|
198
|
+
*
|
|
199
|
+
* Deliberately conservative. `state` is present but optional because many
|
|
200
|
+
* countries have no meaningful subdivision in a postal address (Singapore,
|
|
201
|
+
* Vatican City), and `postalCode` is optional because several have no postal
|
|
202
|
+
* code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
|
|
203
|
+
* the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
|
|
204
|
+
*
|
|
205
|
+
* The required set is exactly what `Fulfillment` needs to be deliverable:
|
|
206
|
+
* `line1`, `city`, `country`.
|
|
207
|
+
*/
|
|
208
|
+
declare const INTERNATIONAL_ADDRESS_FORMAT: AddressFormat;
|
|
209
|
+
/** Look a CANONICAL field up by its key. Extension fields have no `key`. */
|
|
210
|
+
declare function fieldSpec(format: AddressFormat, key: AddressFieldKey): AddressFieldSpec | undefined;
|
|
211
|
+
/**
|
|
212
|
+
* The CANONICAL keys a format marks required.
|
|
213
|
+
*
|
|
214
|
+
* Canonical only, deliberately: callers use this to check that a format can
|
|
215
|
+
* produce a deliverable address (`line1` + `city` + `country`), and an extension
|
|
216
|
+
* name has no meaning in that question. Use `requiredFieldNames` for everything
|
|
217
|
+
* a form must collect.
|
|
218
|
+
*/
|
|
219
|
+
declare function requiredKeys(format: AddressFormat): AddressFieldKey[];
|
|
220
|
+
/** Every field name a format requires, canonical and extension alike. */
|
|
221
|
+
declare function requiredFieldNames(format: AddressFormat): string[];
|
|
222
|
+
/**
|
|
223
|
+
* A capture in progress.
|
|
224
|
+
*
|
|
225
|
+
* Extension values sit FLAT beside the canonical ones — `areaName` alongside
|
|
226
|
+
* `city` — because that is how the order kernel already stores them, and a
|
|
227
|
+
* nested bag would be a second place for the same address to disagree with
|
|
228
|
+
* itself. The index signature is what lets a format declare a field this
|
|
229
|
+
* package has never heard of.
|
|
230
|
+
*/
|
|
231
|
+
type PartialAddress = Partial<Record<AddressFieldKey, string>> & Partial<Pick<ContactAddress, 'line1' | 'city' | 'country'>> & {
|
|
232
|
+
[extension: string]: unknown;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Country code → capture format. The host builds this, exactly as it builds the
|
|
236
|
+
* ledger's `REGISTRY` (`be-prod/.../accounting/country-pack.ts`).
|
|
237
|
+
*/
|
|
238
|
+
type AddressFormatRegistry = Readonly<Record<string, AddressFormat>>;
|
|
239
|
+
/**
|
|
240
|
+
* Resolve the format for a country, falling back to the international one.
|
|
241
|
+
*
|
|
242
|
+
* ## Falling back is the point, not a concession
|
|
243
|
+
*
|
|
244
|
+
* A deployment that has not authored a pack for Norway must still be able to
|
|
245
|
+
* ship to Norway. The fallback captures the deliverable minimum with neutral
|
|
246
|
+
* labels, which is strictly better than either refusing the address or showing
|
|
247
|
+
* a Bangladeshi form to a Norwegian.
|
|
248
|
+
*
|
|
249
|
+
* The lookup is case-insensitive because `country` reaches this from three
|
|
250
|
+
* directions — a form, a stored order, an API payload — and ISO codes get
|
|
251
|
+
* written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
|
|
252
|
+
* at every call site, one of which will be forgotten and will silently fall back
|
|
253
|
+
* to international for its own home country.
|
|
254
|
+
*/
|
|
255
|
+
declare function resolveAddressFormat(country: string | null | undefined, registry: AddressFormatRegistry): AddressFormat;
|
|
256
|
+
/**
|
|
257
|
+
* The countries a deployment has packs for — for a country picker.
|
|
258
|
+
*
|
|
259
|
+
* A deployment that ships internationally should offer every ISO country and let
|
|
260
|
+
* unlisted ones resolve to the fallback; this is for the common case of an
|
|
261
|
+
* operator choosing among the markets actually configured.
|
|
262
|
+
*/
|
|
263
|
+
declare function supportedCountries(registry: AddressFormatRegistry): Array<{
|
|
264
|
+
code: string;
|
|
265
|
+
label: string;
|
|
266
|
+
}>;
|
|
267
|
+
interface AddressFieldError {
|
|
268
|
+
/**
|
|
269
|
+
* The field's write key — a canonical `AddressFieldKey` or an extension name.
|
|
270
|
+
* Typed `string` because a format may declare fields this package does not
|
|
271
|
+
* know; a form matches it against `fieldName(spec)`.
|
|
272
|
+
*/
|
|
273
|
+
key: string;
|
|
274
|
+
/** A closed code — callers map it to their own copy. */
|
|
275
|
+
code: 'required' | 'postal_code_format';
|
|
276
|
+
/** The field's own label, so a default message reads correctly per country. */
|
|
277
|
+
label: string;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Validate a captured address against its country's format.
|
|
281
|
+
*
|
|
282
|
+
* ## Returns errors; does not throw, and does not "clean"
|
|
283
|
+
*
|
|
284
|
+
* A form needs every failure at once to mark every field, so this returns a
|
|
285
|
+
* list. It also never mutates or drops anything: a validator that silently
|
|
286
|
+
* repaired input would make the stored value differ from what the operator
|
|
287
|
+
* typed, and they would have no way to see it.
|
|
288
|
+
*
|
|
289
|
+
* ## Codes, not sentences
|
|
290
|
+
*
|
|
291
|
+
* `code` is closed and stable so a host can translate. `label` rides along
|
|
292
|
+
* because the same `state` field is "Division" in Bangladesh and "Prefecture"
|
|
293
|
+
* in Japan, and a default message assembled from the code alone would say the
|
|
294
|
+
* wrong word in one of them.
|
|
295
|
+
*
|
|
296
|
+
* ## Whitespace is absence
|
|
297
|
+
*
|
|
298
|
+
* `' '` satisfies a naive truthiness check and then prints as a blank line on
|
|
299
|
+
* a courier label. Required means a value, not a character.
|
|
300
|
+
*/
|
|
301
|
+
declare function validateAddress(address: PartialAddress | null | undefined, format: AddressFormat): AddressFieldError[];
|
|
302
|
+
/** Convenience for a submit gate. */
|
|
303
|
+
declare function isAddressValid(address: PartialAddress | null | undefined, format: AddressFormat): boolean;
|
|
304
|
+
/** A readable default message, in the country's own vocabulary. */
|
|
305
|
+
declare function describeAddressError(error: AddressFieldError): string;
|
|
37
306
|
//#endregion
|
|
38
|
-
export { Address, ContactAddress, GeoJsonPoint, GeoPoint, fromGeoJsonPoint, toGeoJsonPoint };
|
|
307
|
+
export { Address, AddressExtensionFieldSpec, AddressFieldError, AddressFieldInput, AddressFieldKey, AddressFieldSpec, AddressFormat, AddressFormatRegistry, AddressTaxonomyPort, AnyAddressFieldSpec, ContactAddress, GeoJsonPoint, GeoPoint, INTERNATIONAL_ADDRESS_FORMAT, PartialAddress, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
package/dist/address.mjs
CHANGED
|
@@ -12,5 +12,194 @@ function fromGeoJsonPoint(g) {
|
|
|
12
12
|
latitude
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
|
+
/** The key a spec writes to, canonical or extension. */
|
|
16
|
+
function fieldName(spec) {
|
|
17
|
+
return "extension" in spec ? spec.name : spec.key;
|
|
18
|
+
}
|
|
19
|
+
/** Narrowing helper — `true` when the spec is an extension. */
|
|
20
|
+
function isExtensionField(spec) {
|
|
21
|
+
return "extension" in spec;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The generic international format — the FALLBACK, and the proof the contract is
|
|
25
|
+
* not BD-shaped.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately conservative. `state` is present but optional because many
|
|
28
|
+
* countries have no meaningful subdivision in a postal address (Singapore,
|
|
29
|
+
* Vatican City), and `postalCode` is optional because several have no postal
|
|
30
|
+
* code at all (Ireland pre-Eircode, Hong Kong, UAE). Requiring either would make
|
|
31
|
+
* the DEFAULT refuse valid addresses — the failure mode a fallback must not have.
|
|
32
|
+
*
|
|
33
|
+
* The required set is exactly what `Fulfillment` needs to be deliverable:
|
|
34
|
+
* `line1`, `city`, `country`.
|
|
35
|
+
*/
|
|
36
|
+
const INTERNATIONAL_ADDRESS_FORMAT = {
|
|
37
|
+
country: "*",
|
|
38
|
+
countryLabel: "Country",
|
|
39
|
+
fields: [
|
|
40
|
+
{
|
|
41
|
+
key: "name",
|
|
42
|
+
label: "Recipient Name",
|
|
43
|
+
input: "text",
|
|
44
|
+
required: true
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: "phone",
|
|
48
|
+
label: "Phone",
|
|
49
|
+
input: "tel",
|
|
50
|
+
required: true
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: "line1",
|
|
54
|
+
label: "Address Line 1",
|
|
55
|
+
input: "text",
|
|
56
|
+
required: true,
|
|
57
|
+
wide: true
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "line2",
|
|
61
|
+
label: "Address Line 2",
|
|
62
|
+
input: "text",
|
|
63
|
+
wide: true
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: "city",
|
|
67
|
+
label: "City",
|
|
68
|
+
input: "text",
|
|
69
|
+
required: true
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "state",
|
|
73
|
+
label: "State / Province / Region",
|
|
74
|
+
input: "text"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
key: "postalCode",
|
|
78
|
+
label: "Postal Code",
|
|
79
|
+
input: "text"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "country",
|
|
83
|
+
label: "Country",
|
|
84
|
+
input: "text",
|
|
85
|
+
required: true
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
/** Look a CANONICAL field up by its key. Extension fields have no `key`. */
|
|
90
|
+
function fieldSpec(format, key) {
|
|
91
|
+
return format.fields.find((f) => !isExtensionField(f) && f.key === key);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The CANONICAL keys a format marks required.
|
|
95
|
+
*
|
|
96
|
+
* Canonical only, deliberately: callers use this to check that a format can
|
|
97
|
+
* produce a deliverable address (`line1` + `city` + `country`), and an extension
|
|
98
|
+
* name has no meaning in that question. Use `requiredFieldNames` for everything
|
|
99
|
+
* a form must collect.
|
|
100
|
+
*/
|
|
101
|
+
function requiredKeys(format) {
|
|
102
|
+
return format.fields.filter((f) => !isExtensionField(f) && !!f.required).map((f) => f.key);
|
|
103
|
+
}
|
|
104
|
+
/** Every field name a format requires, canonical and extension alike. */
|
|
105
|
+
function requiredFieldNames(format) {
|
|
106
|
+
return format.fields.filter((f) => f.required).map(fieldName);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the format for a country, falling back to the international one.
|
|
110
|
+
*
|
|
111
|
+
* ## Falling back is the point, not a concession
|
|
112
|
+
*
|
|
113
|
+
* A deployment that has not authored a pack for Norway must still be able to
|
|
114
|
+
* ship to Norway. The fallback captures the deliverable minimum with neutral
|
|
115
|
+
* labels, which is strictly better than either refusing the address or showing
|
|
116
|
+
* a Bangladeshi form to a Norwegian.
|
|
117
|
+
*
|
|
118
|
+
* The lookup is case-insensitive because `country` reaches this from three
|
|
119
|
+
* directions — a form, a stored order, an API payload — and ISO codes get
|
|
120
|
+
* written `bd` about as often as `BD`. Normalising here beats a `?.toUpperCase()`
|
|
121
|
+
* at every call site, one of which will be forgotten and will silently fall back
|
|
122
|
+
* to international for its own home country.
|
|
123
|
+
*/
|
|
124
|
+
function resolveAddressFormat(country, registry) {
|
|
125
|
+
if (!country) return INTERNATIONAL_ADDRESS_FORMAT;
|
|
126
|
+
return registry[country.trim().toUpperCase()] ?? INTERNATIONAL_ADDRESS_FORMAT;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The countries a deployment has packs for — for a country picker.
|
|
130
|
+
*
|
|
131
|
+
* A deployment that ships internationally should offer every ISO country and let
|
|
132
|
+
* unlisted ones resolve to the fallback; this is for the common case of an
|
|
133
|
+
* operator choosing among the markets actually configured.
|
|
134
|
+
*/
|
|
135
|
+
function supportedCountries(registry) {
|
|
136
|
+
return Object.entries(registry).map(([code, format]) => ({
|
|
137
|
+
code,
|
|
138
|
+
label: format.countryLabel
|
|
139
|
+
})).sort((a, b) => a.label.localeCompare(b.label));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Validate a captured address against its country's format.
|
|
143
|
+
*
|
|
144
|
+
* ## Returns errors; does not throw, and does not "clean"
|
|
145
|
+
*
|
|
146
|
+
* A form needs every failure at once to mark every field, so this returns a
|
|
147
|
+
* list. It also never mutates or drops anything: a validator that silently
|
|
148
|
+
* repaired input would make the stored value differ from what the operator
|
|
149
|
+
* typed, and they would have no way to see it.
|
|
150
|
+
*
|
|
151
|
+
* ## Codes, not sentences
|
|
152
|
+
*
|
|
153
|
+
* `code` is closed and stable so a host can translate. `label` rides along
|
|
154
|
+
* because the same `state` field is "Division" in Bangladesh and "Prefecture"
|
|
155
|
+
* in Japan, and a default message assembled from the code alone would say the
|
|
156
|
+
* wrong word in one of them.
|
|
157
|
+
*
|
|
158
|
+
* ## Whitespace is absence
|
|
159
|
+
*
|
|
160
|
+
* `' '` satisfies a naive truthiness check and then prints as a blank line on
|
|
161
|
+
* a courier label. Required means a value, not a character.
|
|
162
|
+
*/
|
|
163
|
+
function validateAddress(address, format) {
|
|
164
|
+
const errors = [];
|
|
165
|
+
const value = (k) => String(address?.[k] ?? "").trim();
|
|
166
|
+
/**
|
|
167
|
+
* Canonical and extension fields are checked IDENTICALLY, keyed by
|
|
168
|
+
* `fieldName`. A required Delivery Area is enforced exactly as a required
|
|
169
|
+
* city is — an extension that validated more weakly than a core field would
|
|
170
|
+
* be an optional field wearing a required label.
|
|
171
|
+
*/
|
|
172
|
+
for (const field of format.fields) {
|
|
173
|
+
const name = fieldName(field);
|
|
174
|
+
if (field.required && !value(name)) errors.push({
|
|
175
|
+
key: name,
|
|
176
|
+
code: "required",
|
|
177
|
+
label: field.label
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Only when a pattern is declared AND a value was given. An absent optional
|
|
182
|
+
* postal code is not a format error, and a country with no declared pattern
|
|
183
|
+
* must not have one invented for it — see `AddressFormat.postalCodePattern`.
|
|
184
|
+
*/
|
|
185
|
+
const postal = value("postalCode");
|
|
186
|
+
if (format.postalCodePattern && postal && !format.postalCodePattern.test(postal)) errors.push({
|
|
187
|
+
key: "postalCode",
|
|
188
|
+
code: "postal_code_format",
|
|
189
|
+
label: fieldSpec(format, "postalCode")?.label ?? "Postal Code"
|
|
190
|
+
});
|
|
191
|
+
return errors;
|
|
192
|
+
}
|
|
193
|
+
/** Convenience for a submit gate. */
|
|
194
|
+
function isAddressValid(address, format) {
|
|
195
|
+
return validateAddress(address, format).length === 0;
|
|
196
|
+
}
|
|
197
|
+
/** A readable default message, in the country's own vocabulary. */
|
|
198
|
+
function describeAddressError(error) {
|
|
199
|
+
switch (error.code) {
|
|
200
|
+
case "required": return `${error.label} is required`;
|
|
201
|
+
case "postal_code_format": return `${error.label} is not in the expected format`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
15
204
|
//#endregion
|
|
16
|
-
export { fromGeoJsonPoint, toGeoJsonPoint };
|
|
205
|
+
export { INTERNATIONAL_ADDRESS_FORMAT, describeAddressError, fieldName, fieldSpec, fromGeoJsonPoint, isAddressValid, isExtensionField, requiredFieldNames, requiredKeys, resolveAddressFormat, supportedCountries, toGeoJsonPoint, validateAddress };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/primitives",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
|
|
5
5
|
"author": "Classytic",
|
|
6
6
|
"license": "MIT",
|