@nixxie-cms/fields-phone 1.0.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/LICENSE +23 -0
- package/README.md +21 -0
- package/dist/declarations/src/index.d.ts +32 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/nixxie-cms-fields-phone.cjs.d.ts +2 -0
- package/dist/nixxie-cms-fields-phone.cjs.js +105 -0
- package/dist/nixxie-cms-fields-phone.esm.js +100 -0
- package/package.json +33 -0
- package/src/index.ts +114 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nixxie International DMCC
|
|
4
|
+
Portions Copyright (c) 2023 Thinkmill Labs Pty Ltd and contributors
|
|
5
|
+
(this software is derived from the KeystoneJS project, https://keystonejs.com)
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @nixxie-cms/fields-phone
|
|
2
|
+
|
|
3
|
+
A phone number field for Nixxie CMS. Stores E.164-normalised values (`+14155552671`): spaces,
|
|
4
|
+
dashes, dots and parentheses are stripped, a leading `00` becomes `+`, and national numbers are
|
|
5
|
+
promoted using `defaultCountryCode`. Invalid numbers are rejected with a validation error.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { phone } from '@nixxie-cms/fields-phone'
|
|
9
|
+
|
|
10
|
+
fields: {
|
|
11
|
+
mobile: phone({
|
|
12
|
+
defaultCountryCode: '+92', // '0300 1234567' → '+923001234567'
|
|
13
|
+
validation: { isRequired: true },
|
|
14
|
+
isIndexed: 'unique',
|
|
15
|
+
}),
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Also exports `formatPhone(e164)` for display — best-effort grouping (`+1 415 555 2671`): the
|
|
20
|
+
country code is inferred from a small built-in table and the rest is split into groups of 3–4
|
|
21
|
+
digits. It does not apply national formatting conventions; invalid input is returned unchanged.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { TextFieldConfig } from '@nixxie-cms/core/fields';
|
|
2
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types';
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort display formatting for an E.164 number: the country code, a space, then the
|
|
5
|
+
* remaining digits in groups of 3 (the final group absorbs a would-be lone digit, giving 3–4).
|
|
6
|
+
* E.g. `+14155552671` → `+1 415 555 2671`, `+923001234567` → `+92 300 123 4567`. The country
|
|
7
|
+
* code length is inferred from a small built-in table, not a full numbering plan, and no
|
|
8
|
+
* national conventions (area-code parentheses etc.) are applied. Input that is not valid E.164
|
|
9
|
+
* is returned unchanged.
|
|
10
|
+
*/
|
|
11
|
+
export declare function formatPhone(e164: string): string;
|
|
12
|
+
export type PhoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<TextFieldConfig<CollectionTypeInfo>, 'validation'> & {
|
|
13
|
+
validation?: {
|
|
14
|
+
/** Reject saving when no value is present. */
|
|
15
|
+
isRequired?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Country calling code (e.g. `'+92'`) prepended when the user enters a national number —
|
|
19
|
+
* one without a leading `+`. A single leading `0` (the national trunk prefix) is stripped
|
|
20
|
+
* first, so with `'+92'` both `03001234567` and `300 123 4567` become `+923001234567`.
|
|
21
|
+
*/
|
|
22
|
+
defaultCountryCode?: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* A phone number field storing E.164-normalised values (`+` followed by up to 15 digits). Built
|
|
26
|
+
* on the core `text` field: input is cleaned of spaces, dashes, dots and parentheses, a leading
|
|
27
|
+
* `00` becomes `+`, and national numbers are promoted via `defaultCountryCode`. Anything that
|
|
28
|
+
* still isn't valid E.164 is rejected with a validation error. Empty/null values pass through
|
|
29
|
+
* untouched. `isIndexed: 'unique'` is supported via the underlying text field.
|
|
30
|
+
*/
|
|
31
|
+
export declare function phone<CollectionTypeInfo extends BaseCollectionTypeInfo>(config?: PhoneFieldConfig<CollectionTypeInfo>): FieldTypeFunc<CollectionTypeInfo>;
|
|
32
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"../../../src","sources":["index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC9D,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAenF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAehD;AAgBD,MAAM,MAAM,gBAAgB,CAAC,kBAAkB,SAAS,sBAAsB,IAAI,IAAI,CACpF,eAAe,CAAC,kBAAkB,CAAC,EACnC,YAAY,CACb,GAAG;IACF,UAAU,CAAC,EAAE;QACX,8CAA8C;QAC9C,UAAU,CAAC,EAAE,OAAO,CAAA;KACrB,CAAA;IACD;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B,CAAA;AAED;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,kBAAkB,SAAS,sBAAsB,EACrE,MAAM,GAAE,gBAAgB,CAAC,kBAAkB,CAAM,GAChD,aAAa,CAAC,kBAAkB,CAAC,CAgCnC"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export * from "./declarations/src/index.js";
|
|
2
|
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibml4eGllLWNtcy1maWVsZHMtcGhvbmUuY2pzLmQudHMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuL2RlY2xhcmF0aW9ucy9zcmMvaW5kZXguZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSJ9
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
var fields = require('@nixxie-cms/core/fields');
|
|
6
|
+
|
|
7
|
+
/** E.164: a leading `+`, a country code starting 1–9, and 7–15 digits in total. */
|
|
8
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
9
|
+
|
|
10
|
+
/** Country calling codes that are a single digit (NANP and Russia/Kazakhstan). */
|
|
11
|
+
const ONE_DIGIT_CODES = new Set(['1', '7']);
|
|
12
|
+
|
|
13
|
+
/** The ITU-assigned two-digit country calling codes — anything else is three digits. */
|
|
14
|
+
const TWO_DIGIT_CODES = new Set(['20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45', '46', '47', '48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63', '64', '65', '66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Best-effort display formatting for an E.164 number: the country code, a space, then the
|
|
18
|
+
* remaining digits in groups of 3 (the final group absorbs a would-be lone digit, giving 3–4).
|
|
19
|
+
* E.g. `+14155552671` → `+1 415 555 2671`, `+923001234567` → `+92 300 123 4567`. The country
|
|
20
|
+
* code length is inferred from a small built-in table, not a full numbering plan, and no
|
|
21
|
+
* national conventions (area-code parentheses etc.) are applied. Input that is not valid E.164
|
|
22
|
+
* is returned unchanged.
|
|
23
|
+
*/
|
|
24
|
+
function formatPhone(e164) {
|
|
25
|
+
if (!E164.test(e164)) return e164;
|
|
26
|
+
const digits = e164.slice(1);
|
|
27
|
+
let ccLength = 3;
|
|
28
|
+
if (ONE_DIGIT_CODES.has(digits[0])) ccLength = 1;else if (TWO_DIGIT_CODES.has(digits.slice(0, 2))) ccLength = 2;
|
|
29
|
+
const countryCode = digits.slice(0, ccLength);
|
|
30
|
+
const rest = digits.slice(ccLength);
|
|
31
|
+
const groups = [];
|
|
32
|
+
for (let i = 0; i < rest.length; i += 3) groups.push(rest.slice(i, i + 3));
|
|
33
|
+
// Avoid a trailing single digit — merge it into the previous group (3 + 1 → 4).
|
|
34
|
+
if (groups.length > 1 && groups[groups.length - 1].length === 1) {
|
|
35
|
+
groups.splice(groups.length - 2, 2, groups[groups.length - 2] + groups[groups.length - 1]);
|
|
36
|
+
}
|
|
37
|
+
return [`+${countryCode}`, ...groups].join(' ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalise user input towards E.164: strip formatting characters, convert the international
|
|
42
|
+
* `00` prefix to `+`, and — when a default country code is configured — promote a national
|
|
43
|
+
* number (no `+`, optional `0` trunk prefix) to international form.
|
|
44
|
+
*/
|
|
45
|
+
function normalisePhone(value, defaultCountryCode) {
|
|
46
|
+
let v = value.replace(/[\s\-.()]/g, '');
|
|
47
|
+
if (v.startsWith('00')) v = `+${v.slice(2)}`;
|
|
48
|
+
if (!v.startsWith('+') && defaultCountryCode) {
|
|
49
|
+
v = defaultCountryCode + v.replace(/^0/, '');
|
|
50
|
+
}
|
|
51
|
+
return v;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A phone number field storing E.164-normalised values (`+` followed by up to 15 digits). Built
|
|
55
|
+
* on the core `text` field: input is cleaned of spaces, dashes, dots and parentheses, a leading
|
|
56
|
+
* `00` becomes `+`, and national numbers are promoted via `defaultCountryCode`. Anything that
|
|
57
|
+
* still isn't valid E.164 is rejected with a validation error. Empty/null values pass through
|
|
58
|
+
* untouched. `isIndexed: 'unique'` is supported via the underlying text field.
|
|
59
|
+
*/
|
|
60
|
+
function phone(config = {}) {
|
|
61
|
+
const {
|
|
62
|
+
defaultCountryCode,
|
|
63
|
+
hooks,
|
|
64
|
+
validation,
|
|
65
|
+
...rest
|
|
66
|
+
} = config;
|
|
67
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
68
|
+
return fields.text({
|
|
69
|
+
...rest,
|
|
70
|
+
validation: {
|
|
71
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
72
|
+
},
|
|
73
|
+
ui: {
|
|
74
|
+
description: 'International phone number, e.g. +14155552671',
|
|
75
|
+
...rest.ui
|
|
76
|
+
},
|
|
77
|
+
hooks: {
|
|
78
|
+
...hooks,
|
|
79
|
+
resolveInput: args => {
|
|
80
|
+
const value = args.resolvedData[args.fieldKey];
|
|
81
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
82
|
+
return normalisePhone(value, defaultCountryCode);
|
|
83
|
+
},
|
|
84
|
+
validate: async args => {
|
|
85
|
+
var _userValidate;
|
|
86
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
87
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
88
|
+
const {
|
|
89
|
+
resolvedData,
|
|
90
|
+
fieldKey,
|
|
91
|
+
addValidationError,
|
|
92
|
+
operation
|
|
93
|
+
} = args;
|
|
94
|
+
if (operation === 'delete') return;
|
|
95
|
+
const value = resolvedData[fieldKey];
|
|
96
|
+
if (typeof value === 'string' && value.length > 0 && !E164.test(value)) {
|
|
97
|
+
addValidationError('Must be a valid international phone number (E.164, e.g. +14155552671)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
exports.formatPhone = formatPhone;
|
|
105
|
+
exports.phone = phone;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { text } from '@nixxie-cms/core/fields';
|
|
2
|
+
|
|
3
|
+
/** E.164: a leading `+`, a country code starting 1–9, and 7–15 digits in total. */
|
|
4
|
+
const E164 = /^\+[1-9]\d{6,14}$/;
|
|
5
|
+
|
|
6
|
+
/** Country calling codes that are a single digit (NANP and Russia/Kazakhstan). */
|
|
7
|
+
const ONE_DIGIT_CODES = new Set(['1', '7']);
|
|
8
|
+
|
|
9
|
+
/** The ITU-assigned two-digit country calling codes — anything else is three digits. */
|
|
10
|
+
const TWO_DIGIT_CODES = new Set(['20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45', '46', '47', '48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63', '64', '65', '66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98']);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Best-effort display formatting for an E.164 number: the country code, a space, then the
|
|
14
|
+
* remaining digits in groups of 3 (the final group absorbs a would-be lone digit, giving 3–4).
|
|
15
|
+
* E.g. `+14155552671` → `+1 415 555 2671`, `+923001234567` → `+92 300 123 4567`. The country
|
|
16
|
+
* code length is inferred from a small built-in table, not a full numbering plan, and no
|
|
17
|
+
* national conventions (area-code parentheses etc.) are applied. Input that is not valid E.164
|
|
18
|
+
* is returned unchanged.
|
|
19
|
+
*/
|
|
20
|
+
function formatPhone(e164) {
|
|
21
|
+
if (!E164.test(e164)) return e164;
|
|
22
|
+
const digits = e164.slice(1);
|
|
23
|
+
let ccLength = 3;
|
|
24
|
+
if (ONE_DIGIT_CODES.has(digits[0])) ccLength = 1;else if (TWO_DIGIT_CODES.has(digits.slice(0, 2))) ccLength = 2;
|
|
25
|
+
const countryCode = digits.slice(0, ccLength);
|
|
26
|
+
const rest = digits.slice(ccLength);
|
|
27
|
+
const groups = [];
|
|
28
|
+
for (let i = 0; i < rest.length; i += 3) groups.push(rest.slice(i, i + 3));
|
|
29
|
+
// Avoid a trailing single digit — merge it into the previous group (3 + 1 → 4).
|
|
30
|
+
if (groups.length > 1 && groups[groups.length - 1].length === 1) {
|
|
31
|
+
groups.splice(groups.length - 2, 2, groups[groups.length - 2] + groups[groups.length - 1]);
|
|
32
|
+
}
|
|
33
|
+
return [`+${countryCode}`, ...groups].join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalise user input towards E.164: strip formatting characters, convert the international
|
|
38
|
+
* `00` prefix to `+`, and — when a default country code is configured — promote a national
|
|
39
|
+
* number (no `+`, optional `0` trunk prefix) to international form.
|
|
40
|
+
*/
|
|
41
|
+
function normalisePhone(value, defaultCountryCode) {
|
|
42
|
+
let v = value.replace(/[\s\-.()]/g, '');
|
|
43
|
+
if (v.startsWith('00')) v = `+${v.slice(2)}`;
|
|
44
|
+
if (!v.startsWith('+') && defaultCountryCode) {
|
|
45
|
+
v = defaultCountryCode + v.replace(/^0/, '');
|
|
46
|
+
}
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A phone number field storing E.164-normalised values (`+` followed by up to 15 digits). Built
|
|
51
|
+
* on the core `text` field: input is cleaned of spaces, dashes, dots and parentheses, a leading
|
|
52
|
+
* `00` becomes `+`, and national numbers are promoted via `defaultCountryCode`. Anything that
|
|
53
|
+
* still isn't valid E.164 is rejected with a validation error. Empty/null values pass through
|
|
54
|
+
* untouched. `isIndexed: 'unique'` is supported via the underlying text field.
|
|
55
|
+
*/
|
|
56
|
+
function phone(config = {}) {
|
|
57
|
+
const {
|
|
58
|
+
defaultCountryCode,
|
|
59
|
+
hooks,
|
|
60
|
+
validation,
|
|
61
|
+
...rest
|
|
62
|
+
} = config;
|
|
63
|
+
const userValidate = hooks === null || hooks === void 0 ? void 0 : hooks.validate;
|
|
64
|
+
return text({
|
|
65
|
+
...rest,
|
|
66
|
+
validation: {
|
|
67
|
+
isRequired: validation === null || validation === void 0 ? void 0 : validation.isRequired
|
|
68
|
+
},
|
|
69
|
+
ui: {
|
|
70
|
+
description: 'International phone number, e.g. +14155552671',
|
|
71
|
+
...rest.ui
|
|
72
|
+
},
|
|
73
|
+
hooks: {
|
|
74
|
+
...hooks,
|
|
75
|
+
resolveInput: args => {
|
|
76
|
+
const value = args.resolvedData[args.fieldKey];
|
|
77
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
78
|
+
return normalisePhone(value, defaultCountryCode);
|
|
79
|
+
},
|
|
80
|
+
validate: async args => {
|
|
81
|
+
var _userValidate;
|
|
82
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
83
|
+
if (typeof userValidate === 'function') await userValidate(args);else await (userValidate === null || userValidate === void 0 || (_userValidate = userValidate[args.operation]) === null || _userValidate === void 0 ? void 0 : _userValidate.call(userValidate, args));
|
|
84
|
+
const {
|
|
85
|
+
resolvedData,
|
|
86
|
+
fieldKey,
|
|
87
|
+
addValidationError,
|
|
88
|
+
operation
|
|
89
|
+
} = args;
|
|
90
|
+
if (operation === 'delete') return;
|
|
91
|
+
const value = resolvedData[fieldKey];
|
|
92
|
+
if (typeof value === 'string' && value.length > 0 && !E164.test(value)) {
|
|
93
|
+
addValidationError('Must be a valid international phone number (E.164, e.g. +14155552671)');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { formatPhone, phone };
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nixxie-cms/fields-phone",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "dist/nixxie-cms-fields-phone.cjs.js",
|
|
6
|
+
"module": "dist/nixxie-cms-fields-phone.esm.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/nixxie-cms-fields-phone.cjs.js",
|
|
10
|
+
"module": "./dist/nixxie-cms-fields-phone.esm.js",
|
|
11
|
+
"default": "./dist/nixxie-cms-fields-phone.cjs.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@babel/runtime": "^7.24.7"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@nixxie-cms/core": "^1.1.0"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@nixxie-cms/core": "^1.0.1"
|
|
23
|
+
},
|
|
24
|
+
"preconstruct": {
|
|
25
|
+
"entrypoints": [
|
|
26
|
+
"index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nixxiecms/nixxie/tree/main/packages/fields-phone"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { text } from '@nixxie-cms/core/fields'
|
|
2
|
+
import type { TextFieldConfig } from '@nixxie-cms/core/fields'
|
|
3
|
+
import type { BaseCollectionTypeInfo, FieldTypeFunc } from '@nixxie-cms/core/types'
|
|
4
|
+
|
|
5
|
+
/** E.164: a leading `+`, a country code starting 1–9, and 7–15 digits in total. */
|
|
6
|
+
const E164 = /^\+[1-9]\d{6,14}$/
|
|
7
|
+
|
|
8
|
+
/** Country calling codes that are a single digit (NANP and Russia/Kazakhstan). */
|
|
9
|
+
const ONE_DIGIT_CODES = new Set(['1', '7'])
|
|
10
|
+
|
|
11
|
+
/** The ITU-assigned two-digit country calling codes — anything else is three digits. */
|
|
12
|
+
const TWO_DIGIT_CODES = new Set([
|
|
13
|
+
'20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '41', '43', '44', '45', '46', '47',
|
|
14
|
+
'48', '49', '51', '52', '53', '54', '55', '56', '57', '58', '60', '61', '62', '63', '64', '65',
|
|
15
|
+
'66', '81', '82', '84', '86', '90', '91', '92', '93', '94', '95', '98',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort display formatting for an E.164 number: the country code, a space, then the
|
|
20
|
+
* remaining digits in groups of 3 (the final group absorbs a would-be lone digit, giving 3–4).
|
|
21
|
+
* E.g. `+14155552671` → `+1 415 555 2671`, `+923001234567` → `+92 300 123 4567`. The country
|
|
22
|
+
* code length is inferred from a small built-in table, not a full numbering plan, and no
|
|
23
|
+
* national conventions (area-code parentheses etc.) are applied. Input that is not valid E.164
|
|
24
|
+
* is returned unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export function formatPhone(e164: string): string {
|
|
27
|
+
if (!E164.test(e164)) return e164
|
|
28
|
+
const digits = e164.slice(1)
|
|
29
|
+
let ccLength = 3
|
|
30
|
+
if (ONE_DIGIT_CODES.has(digits[0])) ccLength = 1
|
|
31
|
+
else if (TWO_DIGIT_CODES.has(digits.slice(0, 2))) ccLength = 2
|
|
32
|
+
const countryCode = digits.slice(0, ccLength)
|
|
33
|
+
const rest = digits.slice(ccLength)
|
|
34
|
+
const groups: string[] = []
|
|
35
|
+
for (let i = 0; i < rest.length; i += 3) groups.push(rest.slice(i, i + 3))
|
|
36
|
+
// Avoid a trailing single digit — merge it into the previous group (3 + 1 → 4).
|
|
37
|
+
if (groups.length > 1 && groups[groups.length - 1].length === 1) {
|
|
38
|
+
groups.splice(groups.length - 2, 2, groups[groups.length - 2] + groups[groups.length - 1])
|
|
39
|
+
}
|
|
40
|
+
return [`+${countryCode}`, ...groups].join(' ')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalise user input towards E.164: strip formatting characters, convert the international
|
|
45
|
+
* `00` prefix to `+`, and — when a default country code is configured — promote a national
|
|
46
|
+
* number (no `+`, optional `0` trunk prefix) to international form.
|
|
47
|
+
*/
|
|
48
|
+
function normalisePhone(value: string, defaultCountryCode?: string): string {
|
|
49
|
+
let v = value.replace(/[\s\-.()]/g, '')
|
|
50
|
+
if (v.startsWith('00')) v = `+${v.slice(2)}`
|
|
51
|
+
if (!v.startsWith('+') && defaultCountryCode) {
|
|
52
|
+
v = defaultCountryCode + v.replace(/^0/, '')
|
|
53
|
+
}
|
|
54
|
+
return v
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type PhoneFieldConfig<CollectionTypeInfo extends BaseCollectionTypeInfo> = Omit<
|
|
58
|
+
TextFieldConfig<CollectionTypeInfo>,
|
|
59
|
+
'validation'
|
|
60
|
+
> & {
|
|
61
|
+
validation?: {
|
|
62
|
+
/** Reject saving when no value is present. */
|
|
63
|
+
isRequired?: boolean
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Country calling code (e.g. `'+92'`) prepended when the user enters a national number —
|
|
67
|
+
* one without a leading `+`. A single leading `0` (the national trunk prefix) is stripped
|
|
68
|
+
* first, so with `'+92'` both `03001234567` and `300 123 4567` become `+923001234567`.
|
|
69
|
+
*/
|
|
70
|
+
defaultCountryCode?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A phone number field storing E.164-normalised values (`+` followed by up to 15 digits). Built
|
|
75
|
+
* on the core `text` field: input is cleaned of spaces, dashes, dots and parentheses, a leading
|
|
76
|
+
* `00` becomes `+`, and national numbers are promoted via `defaultCountryCode`. Anything that
|
|
77
|
+
* still isn't valid E.164 is rejected with a validation error. Empty/null values pass through
|
|
78
|
+
* untouched. `isIndexed: 'unique'` is supported via the underlying text field.
|
|
79
|
+
*/
|
|
80
|
+
export function phone<CollectionTypeInfo extends BaseCollectionTypeInfo>(
|
|
81
|
+
config: PhoneFieldConfig<CollectionTypeInfo> = {}
|
|
82
|
+
): FieldTypeFunc<CollectionTypeInfo> {
|
|
83
|
+
const { defaultCountryCode, hooks, validation, ...rest } = config
|
|
84
|
+
const userValidate = hooks?.validate as
|
|
85
|
+
| ((args: any) => unknown)
|
|
86
|
+
| { create?: (args: any) => unknown; update?: (args: any) => unknown; delete?: (args: any) => unknown }
|
|
87
|
+
| undefined
|
|
88
|
+
|
|
89
|
+
return text<CollectionTypeInfo>({
|
|
90
|
+
...(rest as TextFieldConfig<CollectionTypeInfo>),
|
|
91
|
+
validation: { isRequired: validation?.isRequired },
|
|
92
|
+
ui: { description: 'International phone number, e.g. +14155552671', ...rest.ui },
|
|
93
|
+
hooks: {
|
|
94
|
+
...hooks,
|
|
95
|
+
resolveInput: (args: any) => {
|
|
96
|
+
const value = args.resolvedData[args.fieldKey]
|
|
97
|
+
if (typeof value !== 'string' || value.length === 0) return value
|
|
98
|
+
return normalisePhone(value, defaultCountryCode)
|
|
99
|
+
},
|
|
100
|
+
validate: async (args: any) => {
|
|
101
|
+
// Preserve any user-supplied validate hook (function or { create, update, delete } form).
|
|
102
|
+
if (typeof userValidate === 'function') await userValidate(args)
|
|
103
|
+
else await userValidate?.[args.operation as 'create' | 'update' | 'delete']?.(args)
|
|
104
|
+
|
|
105
|
+
const { resolvedData, fieldKey, addValidationError, operation } = args
|
|
106
|
+
if (operation === 'delete') return
|
|
107
|
+
const value = resolvedData[fieldKey]
|
|
108
|
+
if (typeof value === 'string' && value.length > 0 && !E164.test(value)) {
|
|
109
|
+
addValidationError('Must be a valid international phone number (E.164, e.g. +14155552671)')
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|