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.
@@ -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
+ };