ti2-bokun 1.0.6 → 1.0.13
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/index.js +644 -286
- package/package.json +2 -2
- package/resolvers/product.js +75 -4
- package/utils/bokunRequest.js +80 -0
- package/utils/bookingFields.js +211 -0
- package/utils/country.js +1177 -0
- package/utils/customFields.js +249 -0
- package/utils/customerFieldConstants.js +35 -0
- package/utils/customerInfoFields.js +321 -0
- package/utils/phone.js +303 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
const { DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS } = require('./customerFieldConstants');
|
|
2
|
+
|
|
3
|
+
const extractQuestionSpecs = root => {
|
|
4
|
+
const out = [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const stack = [root];
|
|
7
|
+
|
|
8
|
+
while (stack.length > 0) {
|
|
9
|
+
const node = stack.pop();
|
|
10
|
+
if (!node) {
|
|
11
|
+
// no-op
|
|
12
|
+
} else if (Array.isArray(node)) {
|
|
13
|
+
node.forEach(item => stack.push(item));
|
|
14
|
+
} else if (typeof node === 'object') {
|
|
15
|
+
if (typeof node.questionId === 'string' && node.questionId.trim() !== '') {
|
|
16
|
+
const key = node.questionId.trim();
|
|
17
|
+
if (!seen.has(key)) {
|
|
18
|
+
seen.add(key);
|
|
19
|
+
out.push(node);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Object.values(node).forEach(value => {
|
|
24
|
+
if (value && (Array.isArray(value) || typeof value === 'object')) stack.push(value);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return out;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const toAnswerValues = answer => {
|
|
33
|
+
if (Array.isArray(answer.values)) return answer.values;
|
|
34
|
+
if (answer.values !== undefined && answer.values !== null) return [answer.values];
|
|
35
|
+
if (answer.value === undefined || answer.value === null) return [];
|
|
36
|
+
return [answer.value];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const toQuestionAnswer = ({ questionId, rawValue }) => {
|
|
40
|
+
if (!questionId) return null;
|
|
41
|
+
const rawValues = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
42
|
+
const values = rawValues
|
|
43
|
+
.map(value => {
|
|
44
|
+
if (value == null) return null;
|
|
45
|
+
if (typeof value === 'object') {
|
|
46
|
+
if (value.value != null) return value.value;
|
|
47
|
+
if (value.id != null) return value.id;
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
})
|
|
51
|
+
.filter(value => value !== null && value !== '');
|
|
52
|
+
|
|
53
|
+
if (values.length === 0) return null;
|
|
54
|
+
return { questionId: String(questionId), values };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const normalizeQuestionAnswerList = answers => (
|
|
58
|
+
(Array.isArray(answers) ? answers : [])
|
|
59
|
+
.map(answer => toQuestionAnswer({
|
|
60
|
+
questionId: answer?.questionId || answer?.id,
|
|
61
|
+
rawValue: toAnswerValues(answer),
|
|
62
|
+
}))
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const mapCustomFieldValuesToAnswers = (customFieldValues, options = {}) => {
|
|
67
|
+
const normalizeFieldValue = options?.normalizeFieldValue;
|
|
68
|
+
const bookingAnswers = [];
|
|
69
|
+
const passengerAnswersByIndex = {};
|
|
70
|
+
const customerAnswers = [];
|
|
71
|
+
|
|
72
|
+
(Array.isArray(customFieldValues) ? customFieldValues : []).forEach(entry => {
|
|
73
|
+
if (!entry || typeof entry !== 'object') return;
|
|
74
|
+
const field = entry.field || {};
|
|
75
|
+
const rawId = field.questionId || field.id || field.fieldId || field.code || field.key;
|
|
76
|
+
if (!rawId) return;
|
|
77
|
+
|
|
78
|
+
const [baseQuestionId, maybeIndex] = String(rawId).split('|');
|
|
79
|
+
const rawValue = typeof normalizeFieldValue === 'function'
|
|
80
|
+
? normalizeFieldValue({ field, value: entry.value })
|
|
81
|
+
: entry.value;
|
|
82
|
+
const answer = toQuestionAnswer({ questionId: baseQuestionId, rawValue });
|
|
83
|
+
if (!answer) return;
|
|
84
|
+
|
|
85
|
+
const passengerIndex = Number(maybeIndex);
|
|
86
|
+
const isPerPassenger = field.isPerUnitItem || Number.isInteger(passengerIndex);
|
|
87
|
+
if (isPerPassenger && Number.isInteger(passengerIndex) && passengerIndex >= 0) {
|
|
88
|
+
passengerAnswersByIndex[passengerIndex] = passengerAnswersByIndex[passengerIndex] || [];
|
|
89
|
+
passengerAnswersByIndex[passengerIndex].push(answer);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (field.isCustomerField) {
|
|
94
|
+
customerAnswers.push(answer);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
bookingAnswers.push(answer);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { bookingAnswers, passengerAnswersByIndex, customerAnswers };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const normalizeQuestionOptions = question => {
|
|
105
|
+
const rawOptions = question?.options
|
|
106
|
+
|| question?.choices
|
|
107
|
+
|| question?.values
|
|
108
|
+
|| question?.allowedValues
|
|
109
|
+
|| [];
|
|
110
|
+
|
|
111
|
+
if (!Array.isArray(rawOptions)) return [];
|
|
112
|
+
|
|
113
|
+
return rawOptions
|
|
114
|
+
.map(option => {
|
|
115
|
+
if (typeof option === 'string') {
|
|
116
|
+
return {
|
|
117
|
+
label: option,
|
|
118
|
+
value: option,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (option && typeof option === 'object') {
|
|
123
|
+
const value = option.value || option.id || option.code || option.key || option.label || option.title;
|
|
124
|
+
const label = option.label || option.title || option.name || option.value || option.id;
|
|
125
|
+
if (value == null || label == null) return null;
|
|
126
|
+
return {
|
|
127
|
+
label: String(label),
|
|
128
|
+
value: String(value),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Derive per-booking / per-participant visibility & requirement flags from
|
|
139
|
+
* a Bokun question/custom-field source.
|
|
140
|
+
*
|
|
141
|
+
* Bokun signals per-passenger questions via `context: 'PASSENGER'` (vs
|
|
142
|
+
* `'BOOKING'` for booking-level / lead-only). When the source object does
|
|
143
|
+
* not carry a `context`, treat it as booking-level (the safer default for
|
|
144
|
+
* legacy product-level customFields that historically appeared once per
|
|
145
|
+
* booking).
|
|
146
|
+
*/
|
|
147
|
+
const deriveQuestionContextFlags = source => {
|
|
148
|
+
const required = Boolean(source?.required);
|
|
149
|
+
const rawContext = typeof source?.context === 'string' ? source.context.toUpperCase() : '';
|
|
150
|
+
const isPassengerContext = rawContext === 'PASSENGER';
|
|
151
|
+
|
|
152
|
+
if (isPassengerContext) {
|
|
153
|
+
return {
|
|
154
|
+
visiblePerBooking: true,
|
|
155
|
+
visiblePerParticipant: true,
|
|
156
|
+
requiredPerBooking: required,
|
|
157
|
+
requiredPerParticipant: required,
|
|
158
|
+
isPerUnitItem: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
visiblePerBooking: true,
|
|
164
|
+
visiblePerParticipant: false,
|
|
165
|
+
requiredPerBooking: required,
|
|
166
|
+
requiredPerParticipant: false,
|
|
167
|
+
isPerUnitItem: false,
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const mapQuestionTypeToFieldType = question => {
|
|
172
|
+
const typeToken = String(
|
|
173
|
+
question?.dataType
|
|
174
|
+
|| question?.type
|
|
175
|
+
|| question?.fieldType
|
|
176
|
+
|| question?.answerType
|
|
177
|
+
|| '',
|
|
178
|
+
).toLowerCase();
|
|
179
|
+
|
|
180
|
+
if (typeToken.includes('options')) return 'extended-option';
|
|
181
|
+
if (typeToken.includes('bool') || typeToken.includes('yesno')) return 'yes-no';
|
|
182
|
+
if (typeToken.includes('checkbox')) return 'yes-no';
|
|
183
|
+
if (typeToken.includes('date') && typeToken.includes('time')) return 'short';
|
|
184
|
+
if (typeToken.includes('date')) return 'date';
|
|
185
|
+
if (typeToken.includes('long') || typeToken.includes('multiline') || typeToken.includes('textarea')) return 'long';
|
|
186
|
+
if (typeToken.includes('integer') || typeToken.includes('number') || typeToken.includes('count')) return 'count';
|
|
187
|
+
if (typeToken.includes('decimal') || typeToken.includes('float') || typeToken.includes('double')) return 'count';
|
|
188
|
+
if (typeToken.includes('select') || typeToken.includes('dropdown') || typeToken.includes('radio')) {
|
|
189
|
+
return 'extended-option';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const options = normalizeQuestionOptions(question);
|
|
193
|
+
if (options.length > 0) return 'extended-option';
|
|
194
|
+
|
|
195
|
+
return 'short';
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const buildCustomFieldsFromQuestions = (questionSources, config = {}) => {
|
|
199
|
+
const excludedQuestionIds = Array.isArray(config.excludedQuestionIds)
|
|
200
|
+
? config.excludedQuestionIds
|
|
201
|
+
: DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS;
|
|
202
|
+
const excludedSet = new Set(excludedQuestionIds.map(String));
|
|
203
|
+
const questions = extractQuestionSpecs(questionSources)
|
|
204
|
+
.filter(question => !excludedSet.has(question.questionId));
|
|
205
|
+
|
|
206
|
+
return questions.map(question => {
|
|
207
|
+
const options = normalizeQuestionOptions(question);
|
|
208
|
+
const field = {
|
|
209
|
+
id: question.questionId,
|
|
210
|
+
title: question.label || question.title || question.question || question.questionId,
|
|
211
|
+
type: mapQuestionTypeToFieldType(question),
|
|
212
|
+
required: Boolean(question.required),
|
|
213
|
+
...deriveQuestionContextFlags(question),
|
|
214
|
+
...(question.placeholder ? { placeholder: String(question.placeholder) } : {}),
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (options.length > 0) {
|
|
218
|
+
field.options = options;
|
|
219
|
+
field.selectMultiple = Boolean(question.selectMultiple);
|
|
220
|
+
}
|
|
221
|
+
return field;
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const normalizeBookingQuestions = questions => (
|
|
226
|
+
(Array.isArray(questions) ? questions : [])
|
|
227
|
+
.map(question => {
|
|
228
|
+
if (!question || typeof question !== 'object') return null;
|
|
229
|
+
const questionId = question.questionId
|
|
230
|
+
|| question.questionCode
|
|
231
|
+
|| (question.id != null ? String(question.id) : null);
|
|
232
|
+
if (!questionId) return null;
|
|
233
|
+
return {
|
|
234
|
+
...question,
|
|
235
|
+
questionId: String(questionId),
|
|
236
|
+
};
|
|
237
|
+
})
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
buildCustomFieldsFromQuestions,
|
|
243
|
+
deriveQuestionContextFlags,
|
|
244
|
+
mapCustomFieldValuesToAnswers,
|
|
245
|
+
extractQuestionSpecs,
|
|
246
|
+
normalizeBookingQuestions,
|
|
247
|
+
normalizeQuestionAnswerList,
|
|
248
|
+
toAnswerValues,
|
|
249
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Bokun's product-level `passengerFields[].field` enum values onto the
|
|
3
|
+
* UI-side traveler field ids. Used to
|
|
4
|
+
* tag a contact field as `visiblePerParticipant` (and optionally
|
|
5
|
+
* `requiredPerParticipant`) when Bokun says it must be collected for every
|
|
6
|
+
* passenger.
|
|
7
|
+
*
|
|
8
|
+
* Keep keys upper-snake to mirror the wire format and ease diffing against
|
|
9
|
+
* Bokun's docs; alias variants (e.g. `POSTCODE` vs `POST_CODE`) are listed
|
|
10
|
+
* explicitly so we don't silently miss either spelling.
|
|
11
|
+
*/
|
|
12
|
+
const BOKUN_PASSENGER_FIELD_TO_UI_ID = {
|
|
13
|
+
FIRST_NAME: 'firstName',
|
|
14
|
+
FIRSTNAME: 'firstName',
|
|
15
|
+
LAST_NAME: 'lastName',
|
|
16
|
+
LASTNAME: 'lastName',
|
|
17
|
+
EMAIL: 'emailAddress',
|
|
18
|
+
EMAILADDRESS: 'emailAddress',
|
|
19
|
+
PHONE_NUMBER: 'phoneNumber',
|
|
20
|
+
PHONENUMBER: 'phoneNumber',
|
|
21
|
+
POST_CODE: 'postCode',
|
|
22
|
+
POSTCODE: 'postCode',
|
|
23
|
+
COUNTRY: 'country',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS = [
|
|
27
|
+
...new Set([
|
|
28
|
+
...Object.values(BOKUN_PASSENGER_FIELD_TO_UI_ID || {}),
|
|
29
|
+
]),
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
BOKUN_PASSENGER_FIELD_TO_UI_ID,
|
|
34
|
+
DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS,
|
|
35
|
+
};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
const {
|
|
2
|
+
normalizePhoneWithCountryCode,
|
|
3
|
+
looksLikeValidPhone,
|
|
4
|
+
} = require('./phone');
|
|
5
|
+
const { buildCountryLookup } = require('./country');
|
|
6
|
+
const {
|
|
7
|
+
BOKUN_PASSENGER_FIELD_TO_UI_ID,
|
|
8
|
+
} = require('./customerFieldConstants');
|
|
9
|
+
|
|
10
|
+
const COUNTRY_VALIDATION_ERROR = 'Please enter a valid country name.';
|
|
11
|
+
const PHONE_VALIDATION_ERROR = 'Please enter a valid phone number.';
|
|
12
|
+
|
|
13
|
+
const ADDRESS_REQUIRED_SUB_FIELDS = [
|
|
14
|
+
'address',
|
|
15
|
+
'postcode',
|
|
16
|
+
'city',
|
|
17
|
+
'country',
|
|
18
|
+
];
|
|
19
|
+
const CUSTOMER_QUESTION_ID_ALIASES = {
|
|
20
|
+
...Object.entries(BOKUN_PASSENGER_FIELD_TO_UI_ID || {}).reduce((acc, [bokunCode, uiId]) => {
|
|
21
|
+
const normalizedBokunCode = String(bokunCode).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
22
|
+
if (normalizedBokunCode) acc[normalizedBokunCode] = uiId;
|
|
23
|
+
return acc;
|
|
24
|
+
}, {}),
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Aliases for outbound main-contact question IDs.
|
|
28
|
+
* Normalize UI/internal IDs to what Bokun expects.
|
|
29
|
+
*/
|
|
30
|
+
const MAIN_CONTACT_QUESTION_ID_ALIASES = {
|
|
31
|
+
city: 'place',
|
|
32
|
+
postCode: 'postcode',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Look up a key in `map` first as-is, then case-insensitively; fall back to raw. */
|
|
36
|
+
const normalizeAliasKey = (map, rawKey) => {
|
|
37
|
+
if (rawKey == null) return rawKey;
|
|
38
|
+
const trimmed = String(rawKey).trim();
|
|
39
|
+
if (trimmed === '') return trimmed;
|
|
40
|
+
return map[trimmed] || map[trimmed.toLowerCase()] || trimmed;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const normalizeCustomerQuestionId = rawId => {
|
|
44
|
+
const id = rawId == null ? '' : String(rawId).trim();
|
|
45
|
+
if (id === '') return '';
|
|
46
|
+
const normalizedId = id.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
47
|
+
return CUSTOMER_QUESTION_ID_ALIASES[normalizedId] || id;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const expandRequiredCustomerQuestionIds = contactInfo => (
|
|
51
|
+
contactInfo.flatMap(entry => {
|
|
52
|
+
const normalizedId = normalizeCustomerQuestionId(entry.field);
|
|
53
|
+
if (String(normalizedId).toLowerCase() !== 'address') return [entry];
|
|
54
|
+
|
|
55
|
+
return ADDRESS_REQUIRED_SUB_FIELDS.map(subFieldId => ({
|
|
56
|
+
...entry,
|
|
57
|
+
field: normalizeCustomerQuestionId(subFieldId),
|
|
58
|
+
}));
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const validateContactFirstName = raw => {
|
|
63
|
+
const trimmed = raw == null ? '' : String(raw).trim();
|
|
64
|
+
if (trimmed === '') {
|
|
65
|
+
return { ok: false, error: 'Please enter a valid first name.' };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, firstName: trimmed };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const validateContactLastName = (raw, fallbackFirstName) => {
|
|
71
|
+
const trimmed = raw == null ? '' : String(raw).trim();
|
|
72
|
+
if (trimmed === '') {
|
|
73
|
+
const fallback = fallbackFirstName == null ? '' : String(fallbackFirstName).trim();
|
|
74
|
+
if (fallback !== '') {
|
|
75
|
+
return { ok: true, lastName: fallback };
|
|
76
|
+
}
|
|
77
|
+
return { ok: false, error: 'Please enter a valid last name.' };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, lastName: trimmed };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const validateContactEmail = raw => {
|
|
83
|
+
const trimmed = raw == null ? '' : String(raw).trim();
|
|
84
|
+
if (trimmed === '') {
|
|
85
|
+
return { ok: false, error: 'Please enter a valid email address.' };
|
|
86
|
+
}
|
|
87
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
88
|
+
return { ok: false, error: 'Please enter a valid email address.' };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, email: trimmed };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Unwrap a country/nationality value to a plain string.
|
|
95
|
+
*
|
|
96
|
+
* `extended-option` UI fields commonly hand the selection back as an option
|
|
97
|
+
* object (e.g. `{ value: 'US', label: 'United States', userInput: true }`)
|
|
98
|
+
* or as a single-element array (e.g. `['US']`). Older UI hosts and direct
|
|
99
|
+
* API callers send a plain string. We accept all three so a dropdown
|
|
100
|
+
* selection isn't silently coerced to `"[object Object]"` and dropped
|
|
101
|
+
* from `mainContactDetails`.
|
|
102
|
+
*/
|
|
103
|
+
const extractCountryInputString = raw => {
|
|
104
|
+
if (raw == null) return '';
|
|
105
|
+
if (typeof raw === 'string') return raw.trim();
|
|
106
|
+
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw).trim();
|
|
107
|
+
if (Array.isArray(raw)) {
|
|
108
|
+
return raw.reduce((acc, entry) => acc || extractCountryInputString(entry), '');
|
|
109
|
+
}
|
|
110
|
+
if (typeof raw === 'object') {
|
|
111
|
+
const candidate = raw.value
|
|
112
|
+
|| raw.isoCode
|
|
113
|
+
|| raw.code
|
|
114
|
+
|| raw.id
|
|
115
|
+
|| raw.label
|
|
116
|
+
|| raw.title
|
|
117
|
+
|| raw.name;
|
|
118
|
+
if (candidate == null) return '';
|
|
119
|
+
return String(candidate).trim();
|
|
120
|
+
}
|
|
121
|
+
return '';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const resolveCountryIsoCode = (raw, countryLookup) => {
|
|
125
|
+
const value = extractCountryInputString(raw);
|
|
126
|
+
if (value === '') return { ok: true, isoCode: '' };
|
|
127
|
+
|
|
128
|
+
const activeLookup = countryLookup || buildCountryLookup();
|
|
129
|
+
|
|
130
|
+
if (activeLookup.getByIso(value)) {
|
|
131
|
+
return { ok: true, isoCode: value.toUpperCase() };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const byName = activeLookup.getByName(value);
|
|
135
|
+
if (byName) {
|
|
136
|
+
return { ok: true, isoCode: String(byName.isoCode).toUpperCase() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
error: COUNTRY_VALIDATION_ERROR,
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const validateContactPhone = ({ phone, country, required = false } = {}) => {
|
|
146
|
+
const rawPhone = phone == null ? '' : String(phone).trim();
|
|
147
|
+
const normalizedCountry = country == null ? '' : String(country).trim().toUpperCase();
|
|
148
|
+
|
|
149
|
+
if (rawPhone === '') {
|
|
150
|
+
if (required) {
|
|
151
|
+
return { ok: false, error: PHONE_VALIDATION_ERROR };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, phone: null };
|
|
154
|
+
}
|
|
155
|
+
const normalizedPhone = normalizePhoneWithCountryCode({
|
|
156
|
+
phone: rawPhone,
|
|
157
|
+
country: normalizedCountry,
|
|
158
|
+
});
|
|
159
|
+
if (!normalizedPhone || !looksLikeValidPhone(normalizedPhone, normalizedCountry)) {
|
|
160
|
+
return { ok: false, error: PHONE_VALIDATION_ERROR };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ok: true, phone: normalizedPhone };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const validateContactInfo = (holder, { countryOptions } = {}) => {
|
|
167
|
+
if (!holder || Object.keys(holder).length === 0) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: 'Contact information is required.',
|
|
171
|
+
errors: ['Contact information is required.'],
|
|
172
|
+
contact: {},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const errors = [];
|
|
176
|
+
|
|
177
|
+
const firstNameCheck = validateContactFirstName(holder.firstName || (holder.name ? holder.name.split(' ')[0] : ''));
|
|
178
|
+
if (!firstNameCheck.ok) errors.push(firstNameCheck.error);
|
|
179
|
+
const firstName = firstNameCheck.firstName || '';
|
|
180
|
+
|
|
181
|
+
const rawLastName = holder.surname || (holder.name ? holder.name.split(' ').slice(1).join(' ') : '');
|
|
182
|
+
const lastNameCheck = validateContactLastName(rawLastName, firstName);
|
|
183
|
+
if (!lastNameCheck.ok) errors.push(lastNameCheck.error);
|
|
184
|
+
const lastName = lastNameCheck.lastName || '';
|
|
185
|
+
|
|
186
|
+
const emailCheck = validateContactEmail(holder.email || holder.emailAddress);
|
|
187
|
+
if (!emailCheck.ok) errors.push(emailCheck.error);
|
|
188
|
+
const email = emailCheck.email || '';
|
|
189
|
+
|
|
190
|
+
// Build the country lookup once (dynamic list first, static fallback) so
|
|
191
|
+
// country and nationality share the same index.
|
|
192
|
+
const countryLookup = buildCountryLookup(countryOptions);
|
|
193
|
+
const countryCheck = resolveCountryIsoCode(holder.country, countryLookup);
|
|
194
|
+
if (!countryCheck.ok) errors.push(countryCheck.error);
|
|
195
|
+
const normalizedCountry = countryCheck.isoCode || '';
|
|
196
|
+
const nationalityCheck = resolveCountryIsoCode(holder.nationality, countryLookup);
|
|
197
|
+
if (!nationalityCheck.ok) errors.push(nationalityCheck.error);
|
|
198
|
+
const normalizedNationality = nationalityCheck.isoCode || '';
|
|
199
|
+
const normalizedPostCode = (
|
|
200
|
+
holder.postCode
|
|
201
|
+
|| holder.postalCode
|
|
202
|
+
|| holder.postcode
|
|
203
|
+
|| holder.zipCode
|
|
204
|
+
|| holder.pinCode
|
|
205
|
+
|| ''
|
|
206
|
+
);
|
|
207
|
+
const normalizedCity = (
|
|
208
|
+
holder.place
|
|
209
|
+
|| holder.city
|
|
210
|
+
|| ''
|
|
211
|
+
);
|
|
212
|
+
const normalizedAddress = (
|
|
213
|
+
holder.address
|
|
214
|
+
|| holder.addressLine1
|
|
215
|
+
|| ''
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const phoneNumberCheck = validateContactPhone({
|
|
219
|
+
phone: holder.phone || holder.phoneNumber,
|
|
220
|
+
country: normalizedCountry,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!phoneNumberCheck.ok) errors.push(phoneNumberCheck.error);
|
|
224
|
+
const phoneNumber = phoneNumberCheck.phone || null;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
ok: errors.length === 0,
|
|
228
|
+
errors,
|
|
229
|
+
contact: {
|
|
230
|
+
firstName,
|
|
231
|
+
lastName,
|
|
232
|
+
email,
|
|
233
|
+
country: normalizedCountry || null,
|
|
234
|
+
nationality: normalizedNationality || null,
|
|
235
|
+
postCode: normalizedPostCode || null,
|
|
236
|
+
city: normalizedCity || null,
|
|
237
|
+
address: normalizedAddress || null,
|
|
238
|
+
phoneNumber: phoneNumber || null,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const getMainContactDetails = contact => {
|
|
244
|
+
if (!contact || Object.keys(contact).length === 0) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
const {
|
|
248
|
+
firstName,
|
|
249
|
+
lastName,
|
|
250
|
+
email,
|
|
251
|
+
country,
|
|
252
|
+
postCode,
|
|
253
|
+
city,
|
|
254
|
+
address,
|
|
255
|
+
phoneNumber,
|
|
256
|
+
} = contact;
|
|
257
|
+
const details = [
|
|
258
|
+
{
|
|
259
|
+
questionId: 'firstName',
|
|
260
|
+
values: [firstName],
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
questionId: 'lastName',
|
|
264
|
+
values: [lastName],
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
questionId: 'email',
|
|
268
|
+
values: [email],
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
const includedQuestionIds = new Set(details.map(detail => detail.questionId));
|
|
272
|
+
const addMainContactDetail = (questionId, rawValue) => {
|
|
273
|
+
const value = rawValue == null ? '' : String(rawValue).trim();
|
|
274
|
+
if (!value || includedQuestionIds.has(questionId)) return;
|
|
275
|
+
details.push({ questionId, values: [value] });
|
|
276
|
+
includedQuestionIds.add(questionId);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
addMainContactDetail('phoneNumber', phoneNumber);
|
|
280
|
+
addMainContactDetail('postCode', postCode);
|
|
281
|
+
addMainContactDetail('country', country);
|
|
282
|
+
addMainContactDetail('city', city);
|
|
283
|
+
addMainContactDetail('address', address);
|
|
284
|
+
|
|
285
|
+
if (address != null && String(address).trim() !== '') {
|
|
286
|
+
const addressFieldValues = {
|
|
287
|
+
address,
|
|
288
|
+
postcode: postCode,
|
|
289
|
+
city,
|
|
290
|
+
country,
|
|
291
|
+
};
|
|
292
|
+
ADDRESS_REQUIRED_SUB_FIELDS.forEach(fieldId => {
|
|
293
|
+
if (fieldId === 'postcode') {
|
|
294
|
+
addMainContactDetail('postCode', addressFieldValues.postcode);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
addMainContactDetail(fieldId, addressFieldValues[fieldId]);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return details;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const normalizeMainContactAnswer = answer => {
|
|
305
|
+
if (!answer || !answer.questionId) return answer;
|
|
306
|
+
const rawQuestionId = String(answer.questionId).trim();
|
|
307
|
+
if (!rawQuestionId) return answer;
|
|
308
|
+
const mapped = normalizeAliasKey(MAIN_CONTACT_QUESTION_ID_ALIASES, rawQuestionId);
|
|
309
|
+
if (mapped === rawQuestionId) return answer;
|
|
310
|
+
return { ...answer, questionId: mapped };
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
module.exports = {
|
|
314
|
+
validateContactInfo,
|
|
315
|
+
getMainContactDetails,
|
|
316
|
+
normalizeMainContactAnswer,
|
|
317
|
+
expandRequiredCustomerQuestionIds,
|
|
318
|
+
extractCountryInputString,
|
|
319
|
+
COUNTRY_VALIDATION_ERROR,
|
|
320
|
+
PHONE_VALIDATION_ERROR,
|
|
321
|
+
};
|