ti2-bokun 1.0.4 → 1.0.8
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 +559 -284
- package/package.json +1 -1
- 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
package/index.js
CHANGED
|
@@ -4,10 +4,40 @@ const assert = require('assert');
|
|
|
4
4
|
const moment = require('moment');
|
|
5
5
|
const jwt = require('jsonwebtoken');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
|
+
const {
|
|
8
|
+
validateContactInfo,
|
|
9
|
+
getMainContactDetails,
|
|
10
|
+
normalizeMainContactAnswer,
|
|
11
|
+
expandRequiredCustomerQuestionIds,
|
|
12
|
+
extractCountryInputString,
|
|
13
|
+
COUNTRY_VALIDATION_ERROR,
|
|
14
|
+
PHONE_VALIDATION_ERROR,
|
|
15
|
+
} = require('./utils/customerInfoFields');
|
|
16
|
+
const {
|
|
17
|
+
buildCustomFieldsFromQuestions,
|
|
18
|
+
mapCustomFieldValuesToAnswers,
|
|
19
|
+
normalizeBookingQuestions,
|
|
20
|
+
normalizeQuestionAnswerList,
|
|
21
|
+
} = require('./utils/customFields');
|
|
22
|
+
const {
|
|
23
|
+
TRAVEL_DATE_FORMAT,
|
|
24
|
+
BOKUN_PASSENGER_FIELD_TO_UI_ID,
|
|
25
|
+
applyPassengerFieldsToUiFields,
|
|
26
|
+
validateRequiredContactFields,
|
|
27
|
+
normalizeCustomFieldValueForBooking,
|
|
28
|
+
extractRequiredQuestions,
|
|
29
|
+
} = require('./utils/bookingFields');
|
|
7
30
|
const { translateProduct } = require('./resolvers/product');
|
|
8
31
|
const { translateAvailability } = require('./resolvers/availability');
|
|
9
32
|
const { translateBooking } = require('./resolvers/booking');
|
|
10
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
fetchCountriesList,
|
|
35
|
+
} = require('./utils/country');
|
|
36
|
+
const {
|
|
37
|
+
buildRequestUrlAndPath,
|
|
38
|
+
getHeaders,
|
|
39
|
+
} = require('./utils/bokunRequest');
|
|
40
|
+
const { DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS } = require('./utils/customerFieldConstants');
|
|
11
41
|
|
|
12
42
|
const DIRECTBOOKING_SOURCE = 'DIRECT_REQUEST';
|
|
13
43
|
const CHECKOUT_OPTION_DIRECTBOOKING = 'CUSTOMER_FULL_PAYMENT';
|
|
@@ -16,6 +46,19 @@ const PAYMENT_METHOD = 'RESERVE_FOR_EXTERNAL_PAYMENT';
|
|
|
16
46
|
|
|
17
47
|
const CONCURRENCY = 3;
|
|
18
48
|
|
|
49
|
+
const COUNTRY_FIELD_IDS = new Set(['country', 'nationality']);
|
|
50
|
+
|
|
51
|
+
const normalizePassengerDetailValue = ({ questionId, value }) => {
|
|
52
|
+
if (value == null) return value;
|
|
53
|
+
const normalizedQuestionId = String(questionId || '').toLowerCase();
|
|
54
|
+
const isCountryLike = normalizedQuestionId === 'country' || normalizedQuestionId === 'nationality';
|
|
55
|
+
if (!isCountryLike || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
56
|
+
|
|
57
|
+
if (value.label != null && String(value.label).trim() !== '') return String(value.label).trim();
|
|
58
|
+
if (value.value != null && String(value.value).trim() !== '') return String(value.value).trim();
|
|
59
|
+
return value;
|
|
60
|
+
};
|
|
61
|
+
|
|
19
62
|
const emptyContact = () => ({
|
|
20
63
|
fullName: null,
|
|
21
64
|
firstName: null,
|
|
@@ -23,23 +66,11 @@ const emptyContact = () => ({
|
|
|
23
66
|
emailAddress: null,
|
|
24
67
|
phoneNumber: null,
|
|
25
68
|
locales: [],
|
|
26
|
-
|
|
69
|
+
postcode: null,
|
|
27
70
|
country: null,
|
|
28
71
|
notes: null,
|
|
29
72
|
});
|
|
30
73
|
|
|
31
|
-
/** Bokun requires a non-empty, plausible email on booking; surface clear errors to the UI. */
|
|
32
|
-
const validateBookingEmail = raw => {
|
|
33
|
-
const trimmed = raw == null ? '' : String(raw).trim();
|
|
34
|
-
if (trimmed === '') {
|
|
35
|
-
return { ok: false, error: 'Email is required. Please enter a valid email address.' };
|
|
36
|
-
}
|
|
37
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
38
|
-
return { ok: false, error: 'Please enter a valid email address.' };
|
|
39
|
-
}
|
|
40
|
-
return { ok: true, email: trimmed };
|
|
41
|
-
};
|
|
42
|
-
|
|
43
74
|
/**
|
|
44
75
|
* Build UI-format unitItems from Bokun booking response.
|
|
45
76
|
* Matches pricingCategories.id / pricePerCategoryUnit.id to show pricingCategories.title as unitName.
|
|
@@ -491,6 +522,33 @@ const toRetailMinor = (price, currencyPrecision = 2) => {
|
|
|
491
522
|
return Math.round(p * (10 ** (Number.isNaN(prec) ? 2 : prec)));
|
|
492
523
|
};
|
|
493
524
|
|
|
525
|
+
const extractBokunErrorMessage = err => {
|
|
526
|
+
const responseData = err && err.response && err.response.data;
|
|
527
|
+
if (!responseData) return err && err.message;
|
|
528
|
+
if (typeof responseData === 'string') return responseData;
|
|
529
|
+
if (responseData.errorMessage) return responseData.errorMessage;
|
|
530
|
+
if (responseData.message) return responseData.message;
|
|
531
|
+
if (responseData.error) {
|
|
532
|
+
if (typeof responseData.error === 'string') return responseData.error;
|
|
533
|
+
if (responseData.error.message) return responseData.error.message;
|
|
534
|
+
}
|
|
535
|
+
if (Array.isArray(responseData.errors) && responseData.errors.length > 0) {
|
|
536
|
+
return responseData.errors
|
|
537
|
+
.map(entry => {
|
|
538
|
+
if (!entry) return null;
|
|
539
|
+
if (typeof entry === 'string') return entry;
|
|
540
|
+
return entry.message || null;
|
|
541
|
+
})
|
|
542
|
+
.filter(Boolean)
|
|
543
|
+
.join(', ');
|
|
544
|
+
}
|
|
545
|
+
if (responseData.details) {
|
|
546
|
+
if (typeof responseData.details === 'string') return responseData.details;
|
|
547
|
+
return JSON.stringify(responseData.details);
|
|
548
|
+
}
|
|
549
|
+
return err && err.message;
|
|
550
|
+
};
|
|
551
|
+
|
|
494
552
|
/**
|
|
495
553
|
* Shape a single availability slot to the UI-expected format.
|
|
496
554
|
* unitId is always string across product, availability, and booking.
|
|
@@ -677,6 +735,7 @@ const buildAvailabilityRoot = ({
|
|
|
677
735
|
currency,
|
|
678
736
|
fallbackDate,
|
|
679
737
|
pricingCategoryLookup,
|
|
738
|
+
pickupPoints = [],
|
|
680
739
|
}) => {
|
|
681
740
|
const availabilityDate = parseAvailabilityDate(availability);
|
|
682
741
|
const fallbackLocalDate = typeof fallbackDate === 'string'
|
|
@@ -758,8 +817,9 @@ const buildAvailabilityRoot = ({
|
|
|
758
817
|
currency: fallbackCurrency,
|
|
759
818
|
offer: availability.offer || availability.offers || null,
|
|
760
819
|
offers: availability.offers,
|
|
761
|
-
pickupAvailable: availability.pickupAvailable || availability.pickupAllotment || false,
|
|
820
|
+
pickupAvailable: pickupPoints.length > 0 || availability.pickupAvailable || availability.pickupAllotment || false,
|
|
762
821
|
pickupRequired: availability.pickupRequired || false,
|
|
822
|
+
pickupPoints,
|
|
763
823
|
pricingFrom: resolvedPricing,
|
|
764
824
|
unitPricingFrom: unitPricing,
|
|
765
825
|
ratePricing: availability.ratePricing,
|
|
@@ -822,74 +882,32 @@ const deriveStableJwtKey = ({ accessKey, secretKey }) => {
|
|
|
822
882
|
};
|
|
823
883
|
|
|
824
884
|
/**
|
|
825
|
-
*
|
|
826
|
-
*
|
|
885
|
+
* Map a Bokun `startPoints[]` entry (from the product payload) to the
|
|
886
|
+
* canonical pickup-point shape used by the availability slot and the
|
|
887
|
+
* pickup-point GraphQL resolver.
|
|
888
|
+
*
|
|
889
|
+
* startPoint field → pickup point field
|
|
890
|
+
* id (number) → id (string)
|
|
891
|
+
* title → name
|
|
892
|
+
* type → pickupType
|
|
893
|
+
* address.addressLine1 + city → address (human-readable string)
|
|
894
|
+
* address.geoPoint.latitude/lng → latitude / longitude
|
|
895
|
+
* pickupTicketDescription → description
|
|
827
896
|
*/
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
const u = new URL(url);
|
|
834
|
-
pathForSigning = u.pathname + u.search;
|
|
835
|
-
} catch (_) {
|
|
836
|
-
// fallback: use pathPart if URL parse fails
|
|
837
|
-
}
|
|
838
|
-
return { url, pathForSigning };
|
|
839
|
-
};
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Bókun signature per https://bokun.dev/booking-api-rest/vU6sCfxwYdJWd1QAcLt12i/configuring-the-platform-for-api-usage-and-authentication/sFiGRpo4detkmrZPcWtQPj
|
|
843
|
-
* String to sign = date + accessKey + HTTP method (uppercase) + path (including query string, no scheme/host).
|
|
844
|
-
* Then HMAC-SHA1 with secret key, then Base64.
|
|
845
|
-
*/
|
|
846
|
-
const generateBokunSignature = ({
|
|
847
|
-
secretKey,
|
|
848
|
-
accessKey,
|
|
849
|
-
date,
|
|
850
|
-
method,
|
|
851
|
-
path,
|
|
852
|
-
}) => {
|
|
853
|
-
const stringToSign = `${date}${accessKey}${method.toUpperCase()}${path}`;
|
|
854
|
-
const signature = crypto
|
|
855
|
-
.createHmac('sha1', secretKey)
|
|
856
|
-
.update(stringToSign)
|
|
857
|
-
.digest('base64');
|
|
858
|
-
return signature;
|
|
859
|
-
};
|
|
860
|
-
|
|
861
|
-
const getHeaders = ({
|
|
862
|
-
accessKey,
|
|
863
|
-
secretKey,
|
|
864
|
-
method,
|
|
865
|
-
path,
|
|
866
|
-
}) => {
|
|
867
|
-
if (typeof secretKey !== 'string' || !secretKey) {
|
|
868
|
-
throw new Error(
|
|
869
|
-
'Bokun API secretKey is required. Configure accessKey and secretKey in the integration token '
|
|
870
|
-
+ 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
if (typeof accessKey !== 'string' || !accessKey) {
|
|
874
|
-
throw new Error(
|
|
875
|
-
'Bokun API accessKey is required. Configure accessKey and secretKey in the integration token '
|
|
876
|
-
+ 'or set ti2_bokun_accessKey and ti2_bokun_secretKey.',
|
|
877
|
-
);
|
|
878
|
-
}
|
|
879
|
-
const date = moment.utc().format('YYYY-MM-DD HH:mm:ss');
|
|
880
|
-
const signature = generateBokunSignature({
|
|
881
|
-
secretKey,
|
|
882
|
-
accessKey,
|
|
883
|
-
date,
|
|
884
|
-
method,
|
|
885
|
-
path,
|
|
886
|
-
});
|
|
887
|
-
|
|
897
|
+
const mapStartPointToPickupPoint = point => {
|
|
898
|
+
if (!point) return null;
|
|
899
|
+
const addr = point.address || {};
|
|
900
|
+
const geoPoint = addr.geoPoint || {};
|
|
901
|
+
const addressParts = [addr.addressLine1, addr.city, addr.state, addr.countryCode].filter(Boolean);
|
|
888
902
|
return {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
'
|
|
903
|
+
id: point.id != null ? String(point.id) : null,
|
|
904
|
+
name: point.title || null,
|
|
905
|
+
description: point.pickupTicketDescription || null,
|
|
906
|
+
address: addressParts.length > 0 ? addressParts.join(', ') : null,
|
|
907
|
+
latitude: geoPoint.latitude ?? null,
|
|
908
|
+
longitude: geoPoint.longitude ?? null,
|
|
909
|
+
pickupType: point.type || null,
|
|
910
|
+
directions: null,
|
|
893
911
|
};
|
|
894
912
|
};
|
|
895
913
|
|
|
@@ -931,9 +949,16 @@ const fetchAvailabilityForProduct = async ({
|
|
|
931
949
|
url: productUrl,
|
|
932
950
|
headers: productHeaders,
|
|
933
951
|
});
|
|
934
|
-
|
|
952
|
+
const productData = R.pathOr({}, ['data'], productResponse);
|
|
953
|
+
pricingCategoryLookup = buildPricingCategoryLookup(productData);
|
|
954
|
+
const showPickupPlaces = Boolean(productData.displaySettings?.showPickupPlaces);
|
|
955
|
+
const pickupPoints = showPickupPlaces
|
|
956
|
+
? (Array.isArray(productData.startPoints) ? productData.startPoints : [])
|
|
957
|
+
.map(mapStartPointToPickupPoint)
|
|
958
|
+
.filter(Boolean)
|
|
959
|
+
: [];
|
|
935
960
|
|
|
936
|
-
//
|
|
961
|
+
// Fetch availability
|
|
937
962
|
const queryString = queryParts.join('&');
|
|
938
963
|
const path = `/activity.json/${productId}/availabilities?${queryString}`;
|
|
939
964
|
const { url, pathForSigning } = buildRequestUrlAndPath(baseUrl, path);
|
|
@@ -943,11 +968,7 @@ const fetchAvailabilityForProduct = async ({
|
|
|
943
968
|
method: 'GET',
|
|
944
969
|
path: pathForSigning,
|
|
945
970
|
});
|
|
946
|
-
const availabilityResponse = await axios({
|
|
947
|
-
method: 'get',
|
|
948
|
-
url,
|
|
949
|
-
headers,
|
|
950
|
-
});
|
|
971
|
+
const availabilityResponse = await axios({ method: 'get', url, headers });
|
|
951
972
|
const availabilities = R.pathOr([], ['data'], availabilityResponse);
|
|
952
973
|
const translated = await Promise.map(availabilities, async availability => {
|
|
953
974
|
const rateMatch = findRateMatch({
|
|
@@ -984,6 +1005,7 @@ const fetchAvailabilityForProduct = async ({
|
|
|
984
1005
|
currency: rateCurrency || currency,
|
|
985
1006
|
fallbackDate: startDate,
|
|
986
1007
|
pricingCategoryLookup,
|
|
1008
|
+
pickupPoints,
|
|
987
1009
|
});
|
|
988
1010
|
|
|
989
1011
|
const translatedAvailability = await translateAvailability({
|
|
@@ -1032,6 +1054,204 @@ const fetchAvailabilityForProduct = async ({
|
|
|
1032
1054
|
}
|
|
1033
1055
|
};
|
|
1034
1056
|
|
|
1057
|
+
// Apply the live (or fallback) Bokun country list as the option set on the
|
|
1058
|
+
// `country` / `nationality` UI fields. NOTE: this is an authoritative override
|
|
1059
|
+
// — any pre-existing `options` / `type` on those fields (e.g. carried over
|
|
1060
|
+
// from a Bokun bookingQuestion) are intentionally replaced so the dropdown
|
|
1061
|
+
// always renders Bokun's canonical country list.
|
|
1062
|
+
const decorateCountryFields = (fieldList, options) => {
|
|
1063
|
+
if (!Array.isArray(fieldList)) return fieldList;
|
|
1064
|
+
if (!Array.isArray(options) || options.length === 0) return fieldList;
|
|
1065
|
+
return fieldList.map(field => {
|
|
1066
|
+
const normalizedFieldId = String(field?.id || '').toLowerCase();
|
|
1067
|
+
if (!field || !COUNTRY_FIELD_IDS.has(normalizedFieldId)) return field;
|
|
1068
|
+
return {
|
|
1069
|
+
...field,
|
|
1070
|
+
type: 'extended-option',
|
|
1071
|
+
selectMultiple: false,
|
|
1072
|
+
options,
|
|
1073
|
+
};
|
|
1074
|
+
});
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
const getContactValidationErrors = contactInfo => (
|
|
1078
|
+
Array.isArray(contactInfo?.errors) && contactInfo.errors.length > 0
|
|
1079
|
+
? contactInfo.errors
|
|
1080
|
+
: [contactInfo?.error || 'Contact information is required.']
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
const shouldRetryContactValidationWithDynamicCountries = (contactInfo, holder) => {
|
|
1084
|
+
if (!contactInfo || contactInfo.ok) return false;
|
|
1085
|
+
const hasCountryInput = Boolean(
|
|
1086
|
+
holder
|
|
1087
|
+
&& (
|
|
1088
|
+
extractCountryInputString(holder.country) !== ''
|
|
1089
|
+
|| extractCountryInputString(holder.nationality) !== ''
|
|
1090
|
+
),
|
|
1091
|
+
);
|
|
1092
|
+
if (!hasCountryInput) return false;
|
|
1093
|
+
|
|
1094
|
+
const errors = getContactValidationErrors(contactInfo);
|
|
1095
|
+
return errors.some(e => e === COUNTRY_VALIDATION_ERROR)
|
|
1096
|
+
&& errors.every(e => e === COUNTRY_VALIDATION_ERROR || e === PHONE_VALIDATION_ERROR);
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
const normalizePassengerFields = passengerFields => (
|
|
1100
|
+
(Array.isArray(passengerFields) ? passengerFields : [])
|
|
1101
|
+
.map(entry => {
|
|
1102
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
1103
|
+
const fieldCode = typeof entry.field === 'string' ? entry.field.trim() : '';
|
|
1104
|
+
if (!fieldCode) return null;
|
|
1105
|
+
const normalizedFieldCode = fieldCode.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
1106
|
+
return {
|
|
1107
|
+
field: BOKUN_PASSENGER_FIELD_TO_UI_ID[normalizedFieldCode] || fieldCode.toLowerCase(),
|
|
1108
|
+
required: Boolean(entry.required),
|
|
1109
|
+
};
|
|
1110
|
+
})
|
|
1111
|
+
.filter(Boolean)
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
async function fetchMandatoryBookingQuestionsForProduct(plugin, {
|
|
1115
|
+
axios,
|
|
1116
|
+
token,
|
|
1117
|
+
productId,
|
|
1118
|
+
}) {
|
|
1119
|
+
try {
|
|
1120
|
+
if (!productId) return { leadPassengerContactInfo: [], passengerContactInfo: [], bookingQuestions: [] };
|
|
1121
|
+
|
|
1122
|
+
const { endpoint, testMode } = token || {};
|
|
1123
|
+
const { accessKey, secretKey } = plugin.getCredentials(token);
|
|
1124
|
+
const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || plugin.endpoint || 'https://api.bokun.io');
|
|
1125
|
+
|
|
1126
|
+
const productPath = `/activity.json/${encodeURIComponent(productId)}`;
|
|
1127
|
+
const { url: productUrl, pathForSigning: productPathForSigning } = buildRequestUrlAndPath(
|
|
1128
|
+
baseUrl,
|
|
1129
|
+
productPath,
|
|
1130
|
+
);
|
|
1131
|
+
const productHeaders = getHeaders({
|
|
1132
|
+
accessKey,
|
|
1133
|
+
secretKey,
|
|
1134
|
+
method: 'GET',
|
|
1135
|
+
path: productPathForSigning,
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
const productResponse = await axios({
|
|
1139
|
+
method: 'get',
|
|
1140
|
+
url: productUrl,
|
|
1141
|
+
headers: productHeaders,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
const productData = R.pathOr({}, ['data'], productResponse);
|
|
1145
|
+
const leadPassengerContactInfo = normalizePassengerFields(
|
|
1146
|
+
R.pathOr([], ['mainContactFields'], productData),
|
|
1147
|
+
);
|
|
1148
|
+
const passengerContactInfo = normalizePassengerFields(
|
|
1149
|
+
R.pathOr([], ['passengerFields'], productData),
|
|
1150
|
+
);
|
|
1151
|
+
const bookingQuestions = normalizeBookingQuestions(
|
|
1152
|
+
R.pathOr([], ['bookingQuestions'], productData),
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
return { leadPassengerContactInfo, passengerContactInfo, bookingQuestions };
|
|
1156
|
+
} catch (err) {
|
|
1157
|
+
// Best-effort enrichment only; keep static fields if remote question
|
|
1158
|
+
// lookup fails. Use console.error so operators still see auth/perm
|
|
1159
|
+
// failures, and include the HTTP status when available.
|
|
1160
|
+
const status = err.response?.status;
|
|
1161
|
+
console.error(
|
|
1162
|
+
`Could not fetch mandatory booking questions for product ${String(productId)} (status ${status || 'n/a'}):`,
|
|
1163
|
+
err.response?.data || err.message,
|
|
1164
|
+
);
|
|
1165
|
+
return { leadPassengerContactInfo: [], passengerContactInfo: [], bookingQuestions: [] };
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function toTranslatedBooking({
|
|
1170
|
+
bookingPayload,
|
|
1171
|
+
bookingTypeDefs,
|
|
1172
|
+
bookingQuery,
|
|
1173
|
+
baseUrl,
|
|
1174
|
+
tenant,
|
|
1175
|
+
}) {
|
|
1176
|
+
let booking = R.path(['data', 'booking'], bookingPayload)
|
|
1177
|
+
|| R.path(['data'], bookingPayload)
|
|
1178
|
+
|| bookingPayload?.booking
|
|
1179
|
+
|| bookingPayload;
|
|
1180
|
+
const redirectUrl = R.path(['data', 'redirectRequest', 'url'], bookingPayload);
|
|
1181
|
+
if (typeof redirectUrl === 'string' && /^https?:\/\//i.test(redirectUrl.trim()) && !booking.publicUrl) {
|
|
1182
|
+
booking = { ...booking, publicUrl: redirectUrl.trim() };
|
|
1183
|
+
}
|
|
1184
|
+
const bookingUnitItems = buildUnitItemsForBooking(booking, {});
|
|
1185
|
+
const rootValue = { ...booking, unitItems: bookingUnitItems };
|
|
1186
|
+
const translatedBooking = await translateBooking({
|
|
1187
|
+
rootValue,
|
|
1188
|
+
typeDefs: bookingTypeDefs,
|
|
1189
|
+
query: bookingQuery,
|
|
1190
|
+
apiEndpoint: baseUrl,
|
|
1191
|
+
tenant,
|
|
1192
|
+
});
|
|
1193
|
+
if (Array.isArray(bookingUnitItems) && bookingUnitItems.length > 0) {
|
|
1194
|
+
translatedBooking.unitItems = bookingUnitItems;
|
|
1195
|
+
}
|
|
1196
|
+
return { booking: translatedBooking };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function getActivityInfo({
|
|
1200
|
+
availabilityKey,
|
|
1201
|
+
jwtKey,
|
|
1202
|
+
unitItems,
|
|
1203
|
+
currency,
|
|
1204
|
+
product,
|
|
1205
|
+
}) {
|
|
1206
|
+
let availabilityMetadata = {};
|
|
1207
|
+
|
|
1208
|
+
if (availabilityKey) {
|
|
1209
|
+
try {
|
|
1210
|
+
availabilityMetadata = jwt.verify(availabilityKey, jwtKey);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
console.error('Error decoding availability key:', err.message);
|
|
1213
|
+
throw new Error('Invalid availabilityKey');
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const keyUnitItems = Array.isArray(availabilityMetadata.unitItems) ? availabilityMetadata.unitItems : [];
|
|
1218
|
+
const providedUnitItems = Array.isArray(unitItems) ? unitItems : [];
|
|
1219
|
+
const finalUnitItems = keyUnitItems.length > 0 ? keyUnitItems : providedUnitItems;
|
|
1220
|
+
|
|
1221
|
+
const bookingCurrency = currency
|
|
1222
|
+
|| availabilityMetadata.currency
|
|
1223
|
+
|| 'USD';
|
|
1224
|
+
|
|
1225
|
+
const rateIdFromKey = availabilityMetadata.rateId;
|
|
1226
|
+
const parsedRateId = rateIdFromKey && !Number.isNaN(Number(rateIdFromKey))
|
|
1227
|
+
? Number(rateIdFromKey)
|
|
1228
|
+
: null;
|
|
1229
|
+
|
|
1230
|
+
const startTimeIdRaw = availabilityMetadata.startTimeId || availabilityMetadata.availabilityId || null;
|
|
1231
|
+
const parsedStartTimeId = startTimeIdRaw && !Number.isNaN(Number(startTimeIdRaw))
|
|
1232
|
+
? Number(startTimeIdRaw)
|
|
1233
|
+
: startTimeIdRaw;
|
|
1234
|
+
|
|
1235
|
+
const activityIdFromKey = availabilityMetadata.productId;
|
|
1236
|
+
const bookingDate = availabilityMetadata.localDateTimeStart
|
|
1237
|
+
? moment(availabilityMetadata.localDateTimeStart).format(TRAVEL_DATE_FORMAT)
|
|
1238
|
+
: null;
|
|
1239
|
+
|
|
1240
|
+
const activityIdRaw = activityIdFromKey || (product && (product.productId || product.id));
|
|
1241
|
+
const parsedActivityId = activityIdRaw != null && !Number.isNaN(Number(activityIdRaw))
|
|
1242
|
+
? Number(activityIdRaw)
|
|
1243
|
+
: activityIdRaw;
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
finalUnitItems,
|
|
1247
|
+
bookingCurrency,
|
|
1248
|
+
parsedRateId,
|
|
1249
|
+
parsedStartTimeId,
|
|
1250
|
+
bookingDate,
|
|
1251
|
+
parsedActivityId,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1035
1255
|
class Plugin {
|
|
1036
1256
|
constructor(params) {
|
|
1037
1257
|
Object.entries(params).forEach(([attr, value]) => {
|
|
@@ -1042,6 +1262,13 @@ class Plugin {
|
|
|
1042
1262
|
this.accessKey = this.accessKey || process.env.ti2_bokun_accessKey;
|
|
1043
1263
|
this.secretKey = this.secretKey || process.env.ti2_bokun_secretKey;
|
|
1044
1264
|
|
|
1265
|
+
// Per-instance cache for the Bokun country list keyed by baseUrl.
|
|
1266
|
+
// Populated lazily by getCreateBookingFields and reused across calls
|
|
1267
|
+
// for the configured TTL (defaults to 7 days). Each cache entry is
|
|
1268
|
+
// either { options, expiresAt } once resolved, or { promise, expiresAt: 0 }
|
|
1269
|
+
// while a fetch is in flight (de-duplicates concurrent callers).
|
|
1270
|
+
this.countriesCacheByBaseUrl = new Map();
|
|
1271
|
+
|
|
1045
1272
|
this.getCredentials = (token = {}) => {
|
|
1046
1273
|
const accessKey = token.accessKey || this.accessKey || process.env.ti2_bokun_accessKey;
|
|
1047
1274
|
const secretKey = token.secretKey || this.secretKey || process.env.ti2_bokun_secretKey;
|
|
@@ -1275,72 +1502,66 @@ class Plugin {
|
|
|
1275
1502
|
return this.searchAvailability({ axios, token, payload, typeDefsAndQueries });
|
|
1276
1503
|
}
|
|
1277
1504
|
|
|
1278
|
-
async
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
const
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
rootValue: point,
|
|
1308
|
-
typeDefs: pickupTypeDefs,
|
|
1309
|
-
query: pickupQuery,
|
|
1310
|
-
})
|
|
1311
|
-
), { concurrency: CONCURRENCY });
|
|
1505
|
+
async getCreateBookingFields({ axios, token, payload = {}, query = {} } = {}) {
|
|
1506
|
+
const requestData = Object.keys(query).length > 0 ? query : payload;
|
|
1507
|
+
const { productId } = requestData;
|
|
1508
|
+
|
|
1509
|
+
// Fetch the per-product booking questions and the global country list in
|
|
1510
|
+
// parallel. The country list is cached for COUNTRIES_CACHE_TTL_MS so
|
|
1511
|
+
// subsequent calls reuse the result and only the question fetch hits the
|
|
1512
|
+
// network. We only allow a country fetch when we'd already be calling
|
|
1513
|
+
// Bokun for questions — otherwise we return the static list and avoid
|
|
1514
|
+
// adding HTTP traffic to caller-supplied flows.
|
|
1515
|
+
const [productQuestionPayload, countryOptions] = await Promise.all([
|
|
1516
|
+
fetchMandatoryBookingQuestionsForProduct(this, { axios, token, productId }),
|
|
1517
|
+
fetchCountriesList(this, {
|
|
1518
|
+
axios,
|
|
1519
|
+
token,
|
|
1520
|
+
useNetwork: true,
|
|
1521
|
+
}),
|
|
1522
|
+
]);
|
|
1523
|
+
const { leadPassengerContactInfo, passengerContactInfo, bookingQuestions } = productQuestionPayload;
|
|
1524
|
+
const expandedLeadPassengerContactInfo = expandRequiredCustomerQuestionIds(leadPassengerContactInfo);
|
|
1525
|
+
const expandedPassengerContactInfo = expandRequiredCustomerQuestionIds(passengerContactInfo);
|
|
1526
|
+
const fieldsWithPassengerFlags = applyPassengerFieldsToUiFields(
|
|
1527
|
+
expandedLeadPassengerContactInfo,
|
|
1528
|
+
expandedPassengerContactInfo,
|
|
1529
|
+
);
|
|
1530
|
+
const fields = decorateCountryFields(fieldsWithPassengerFlags, countryOptions);
|
|
1531
|
+
const customFields = buildCustomFieldsFromQuestions([
|
|
1532
|
+
bookingQuestions,
|
|
1533
|
+
]);
|
|
1312
1534
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
}
|
|
1535
|
+
const excludedCustomerFieldIds = new Set(
|
|
1536
|
+
(Array.isArray(DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS) ? DEFAULT_EXCLUDED_CUSTOMER_QUESTION_IDS : [])
|
|
1537
|
+
.map(id => String(id || '').trim())
|
|
1538
|
+
.filter(Boolean),
|
|
1539
|
+
);
|
|
1319
1540
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
{ id: 'lastName', title: 'Last name', type: 'short', required: true },
|
|
1325
|
-
{ id: 'emailAddress', title: 'Email', type: 'short', required: true },
|
|
1326
|
-
{ id: 'phoneNumber', title: 'Phone', type: 'short', required: false },
|
|
1327
|
-
{ id: 'country', title: 'Country', type: 'short', required: false },
|
|
1328
|
-
{ id: 'postalCode', title: 'Postal code', type: 'short', required: false },
|
|
1329
|
-
];
|
|
1330
|
-
|
|
1331
|
-
// Optional per-product custom booking questions can be added later.
|
|
1332
|
-
const customFields = [];
|
|
1333
|
-
return { fields, customFields };
|
|
1334
|
-
}
|
|
1541
|
+
const additionalCustomerFields = fields
|
|
1542
|
+
.filter(field => field && field.visiblePerBooking)
|
|
1543
|
+
.filter(field => !excludedCustomerFieldIds.has(String(field.id || '').trim()))
|
|
1544
|
+
.map(field => ({ ...field, required: Boolean(field.requiredPerBooking), isCustomerField: true }));
|
|
1335
1545
|
|
|
1336
|
-
|
|
1337
|
-
return this.getCreateBookingFields(args);
|
|
1546
|
+
return { fields, customFields, additionalCustomerFields };
|
|
1338
1547
|
}
|
|
1339
1548
|
|
|
1340
1549
|
/**
|
|
1341
1550
|
* Create a booking using Bokun checkout endpoints.
|
|
1342
|
-
* -
|
|
1343
|
-
* -
|
|
1551
|
+
* - POST /checkout.json/submit with directBooking
|
|
1552
|
+
* - POST /checkout.json/confirm-reserved/{code}
|
|
1553
|
+
*
|
|
1554
|
+
* @param {Object} args
|
|
1555
|
+
* @param {Object} args.axios - axios instance for the outbound calls
|
|
1556
|
+
* @param {Object} args.token - vendor token (provides credentials/endpoint)
|
|
1557
|
+
* @param {Object} args.payload - booking payload from the UI
|
|
1558
|
+
* @param {Object} args.typeDefsAndQueries - GraphQL typeDefs/query used to translate the response
|
|
1559
|
+
* @returns {Promise<{ booking: Object|null, error?: string }>} On success
|
|
1560
|
+
* `{ booking }`. On checkout failures, missing confirmation code, or
|
|
1561
|
+
* options preflight validation failure, `{ booking: null, error }`.
|
|
1562
|
+
* @throws {Error} When `holder` contact validation fails (invalid/missing
|
|
1563
|
+
* email, etc.) or when `availabilityKey` cannot be decoded — same contract
|
|
1564
|
+
* as earlier versions that threw on invalid contact email.
|
|
1344
1565
|
*/
|
|
1345
1566
|
async createBooking({
|
|
1346
1567
|
axios,
|
|
@@ -1355,53 +1576,80 @@ class Plugin {
|
|
|
1355
1576
|
integrationIsDirectBooking = false,
|
|
1356
1577
|
product,
|
|
1357
1578
|
unitItems,
|
|
1579
|
+
participants = [],
|
|
1358
1580
|
currency,
|
|
1581
|
+
mainContactDetails: providedMainContactDetails = [],
|
|
1582
|
+
activityAnswers = [],
|
|
1583
|
+
customFieldValues = [],
|
|
1584
|
+
dateFormat: customFieldDateFormat,
|
|
1585
|
+
sendNotificationToMainContact = true,
|
|
1359
1586
|
} = payload;
|
|
1360
|
-
|
|
1361
1587
|
const { endpoint, testMode } = token || {};
|
|
1362
1588
|
const { accessKey, secretKey } = this.getCredentials(token);
|
|
1363
1589
|
const jwtKey = this.getJwtKey(token);
|
|
1364
1590
|
assert(jwtKey, 'JWT secret should be set for availability keys');
|
|
1365
1591
|
|
|
1366
1592
|
const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || this.endpoint || 'https://api.bokun.io');
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const bookingCurrency = currency
|
|
1383
|
-
|| availabilityMetadata.currency
|
|
1384
|
-
|| 'USD';
|
|
1385
|
-
|
|
1386
|
-
const rateIdFromKey = availabilityMetadata.rateId;
|
|
1387
|
-
const parsedRateId = rateIdFromKey && !Number.isNaN(Number(rateIdFromKey))
|
|
1388
|
-
? Number(rateIdFromKey)
|
|
1389
|
-
: null;
|
|
1390
|
-
|
|
1391
|
-
const startTimeIdRaw = availabilityMetadata.startTimeId || availabilityMetadata.availabilityId || null;
|
|
1392
|
-
const parsedStartTimeId = startTimeIdRaw && !Number.isNaN(Number(startTimeIdRaw))
|
|
1393
|
-
? Number(startTimeIdRaw)
|
|
1394
|
-
: startTimeIdRaw;
|
|
1395
|
-
|
|
1396
|
-
const activityIdFromKey = availabilityMetadata.productId;
|
|
1397
|
-
const bookingDate = availabilityMetadata.localDateTimeStart
|
|
1398
|
-
? moment(availabilityMetadata.localDateTimeStart).format('YYYY-MM-DD')
|
|
1399
|
-
: null;
|
|
1593
|
+
const {
|
|
1594
|
+
finalUnitItems,
|
|
1595
|
+
bookingCurrency,
|
|
1596
|
+
parsedRateId,
|
|
1597
|
+
parsedStartTimeId,
|
|
1598
|
+
bookingDate,
|
|
1599
|
+
parsedActivityId,
|
|
1600
|
+
} = getActivityInfo({
|
|
1601
|
+
availabilityKey,
|
|
1602
|
+
jwtKey,
|
|
1603
|
+
unitItems,
|
|
1604
|
+
currency,
|
|
1605
|
+
product,
|
|
1606
|
+
});
|
|
1400
1607
|
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1608
|
+
// Validate basic contact details first (no network). If the only failure
|
|
1609
|
+
// is country resolution (or phone format dependent on country), retry once
|
|
1610
|
+
// with the live country list so newly-added Bokun countries are accepted.
|
|
1611
|
+
let contactInfo = validateContactInfo(holder);
|
|
1612
|
+
if (shouldRetryContactValidationWithDynamicCountries(contactInfo, holder)) {
|
|
1613
|
+
const countryOptions = await fetchCountriesList(this, { axios, token });
|
|
1614
|
+
contactInfo = validateContactInfo(holder, { countryOptions });
|
|
1615
|
+
}
|
|
1616
|
+
if (!contactInfo.ok) {
|
|
1617
|
+
const contactErrors = getContactValidationErrors(contactInfo);
|
|
1618
|
+
const contactErrorMessage = contactErrors.join(', ');
|
|
1619
|
+
console.error('Error creating booking: ', contactErrorMessage);
|
|
1620
|
+
throw new Error(contactErrorMessage);
|
|
1621
|
+
}
|
|
1622
|
+
const defaultMainContactDetails = getMainContactDetails(contactInfo.contact);
|
|
1623
|
+
const providedMainContactDetailsNormalized = normalizeQuestionAnswerList(providedMainContactDetails);
|
|
1624
|
+
const mergedMainContactDetailsByQuestionId = {};
|
|
1625
|
+
defaultMainContactDetails.forEach(answer => {
|
|
1626
|
+
const normalizedAnswer = normalizeMainContactAnswer(answer);
|
|
1627
|
+
if (!normalizedAnswer?.questionId) return;
|
|
1628
|
+
mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
|
|
1629
|
+
});
|
|
1630
|
+
providedMainContactDetailsNormalized.forEach(answer => {
|
|
1631
|
+
const normalizedAnswer = normalizeMainContactAnswer(answer);
|
|
1632
|
+
if (!normalizedAnswer?.questionId) return;
|
|
1633
|
+
mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
|
|
1634
|
+
});
|
|
1635
|
+
const normalizedActivityAnswers = normalizeQuestionAnswerList(activityAnswers);
|
|
1636
|
+
const {
|
|
1637
|
+
bookingAnswers,
|
|
1638
|
+
passengerAnswersByIndex,
|
|
1639
|
+
customerAnswers,
|
|
1640
|
+
} = mapCustomFieldValuesToAnswers(customFieldValues, {
|
|
1641
|
+
normalizeFieldValue: ({ field, value }) => normalizeCustomFieldValueForBooking({
|
|
1642
|
+
field,
|
|
1643
|
+
value,
|
|
1644
|
+
inputDateFormat: customFieldDateFormat,
|
|
1645
|
+
}),
|
|
1646
|
+
});
|
|
1647
|
+
customerAnswers.forEach(answer => {
|
|
1648
|
+
const normalizedAnswer = normalizeMainContactAnswer(answer);
|
|
1649
|
+
if (!normalizedAnswer?.questionId) return;
|
|
1650
|
+
mergedMainContactDetailsByQuestionId[normalizedAnswer.questionId] = normalizedAnswer;
|
|
1651
|
+
});
|
|
1652
|
+
const mainContactDetails = Object.values(mergedMainContactDetailsByQuestionId);
|
|
1405
1653
|
|
|
1406
1654
|
const source = DIRECTBOOKING_SOURCE;
|
|
1407
1655
|
const checkoutOption = integrationIsDirectBooking
|
|
@@ -1409,71 +1657,88 @@ class Plugin {
|
|
|
1409
1657
|
: CHECKOUT_OPTION_AGENT_RESERVATION;
|
|
1410
1658
|
const paymentMethod = PAYMENT_METHOD;
|
|
1411
1659
|
|
|
1412
|
-
const toTranslatedBooking = async bookingPayload => {
|
|
1413
|
-
let booking = R.path(['data', 'booking'], bookingPayload)
|
|
1414
|
-
|| R.path(['data'], bookingPayload)
|
|
1415
|
-
|| bookingPayload?.booking
|
|
1416
|
-
|| bookingPayload;
|
|
1417
|
-
const redirectUrl = R.path(['data', 'redirectRequest', 'url'], bookingPayload);
|
|
1418
|
-
if (typeof redirectUrl === 'string' && /^https?:\/\//i.test(redirectUrl.trim()) && !booking.publicUrl) {
|
|
1419
|
-
booking = { ...booking, publicUrl: redirectUrl.trim() };
|
|
1420
|
-
}
|
|
1421
|
-
const bookingUnitItems = buildUnitItemsForBooking(booking, {});
|
|
1422
|
-
const rootValue = { ...booking, unitItems: bookingUnitItems };
|
|
1423
|
-
const translatedBooking = await translateBooking({
|
|
1424
|
-
rootValue,
|
|
1425
|
-
typeDefs: bookingTypeDefs,
|
|
1426
|
-
query: bookingQuery,
|
|
1427
|
-
apiEndpoint: baseUrl,
|
|
1428
|
-
tenant: token.tenant,
|
|
1429
|
-
});
|
|
1430
|
-
if (Array.isArray(bookingUnitItems) && bookingUnitItems.length > 0) {
|
|
1431
|
-
translatedBooking.unitItems = bookingUnitItems;
|
|
1432
|
-
}
|
|
1433
|
-
return { booking: translatedBooking };
|
|
1434
|
-
};
|
|
1435
|
-
|
|
1436
1660
|
// Booking flow via checkout:
|
|
1437
1661
|
// 1) POST /checkout.json/submit with directBooking
|
|
1438
1662
|
// 2) POST /checkout.json/confirm-reserved/{code}
|
|
1439
1663
|
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1664
|
+
// Expand unit items so every passenger gets its own entry (quantity: 1).
|
|
1665
|
+
// A unit item with quantity N becomes N individual entries so that
|
|
1666
|
+
// participants[i].fields maps cleanly onto the i-th passenger.
|
|
1667
|
+
const expandedUnitItems = [];
|
|
1668
|
+
(Array.isArray(finalUnitItems) ? finalUnitItems : []).forEach(item => {
|
|
1669
|
+
const qty = Number(item.quantity) || 1;
|
|
1670
|
+
let q = 0;
|
|
1671
|
+
while (q < qty) {
|
|
1672
|
+
expandedUnitItems.push({ ...item, quantity: 1 });
|
|
1673
|
+
q += 1;
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1444
1676
|
|
|
1677
|
+
const passengersArray = expandedUnitItems.map((item, passengerIndex) => {
|
|
1678
|
+
// Answers from the JWT availability key (booking-question answers per passenger)
|
|
1679
|
+
const itemAnswers = normalizeQuestionAnswerList(item.answers)
|
|
1680
|
+
.map(a => ({ ...a, passengerIndex }));
|
|
1681
|
+
|
|
1682
|
+
// Per-passenger answers from participants[i].fields (UI form).
|
|
1683
|
+
// Each entry: { field: { id, type, isPerUnitItem, ... }, value }
|
|
1684
|
+
const participant = Array.isArray(participants) ? participants[passengerIndex] : undefined;
|
|
1685
|
+
// customFieldValues → per-passenger booking-question answers (sent as answers with passengerIndex)
|
|
1686
|
+
const participantCustomFieldValues = participant?.customFieldValues || [];
|
|
1687
|
+
// fields → per-passenger contact info (handled separately, explained below)
|
|
1688
|
+
const participantContactFields = participant?.fields || [];
|
|
1689
|
+
|
|
1690
|
+
console.log('SACHIN participantCustomFieldValues: ', JSON.stringify(participantCustomFieldValues, null, 2));
|
|
1691
|
+
console.log('SACHIN participantContactFields: ', JSON.stringify(participantContactFields, null, 2));
|
|
1692
|
+
const participantAnswers = participantCustomFieldValues
|
|
1693
|
+
.map(entry => {
|
|
1694
|
+
const questionId = entry?.field?.id || entry?.field?.questionId;
|
|
1695
|
+
if (!questionId) return null;
|
|
1696
|
+
const raw = entry.value;
|
|
1697
|
+
const values = (Array.isArray(raw) ? raw : [raw])
|
|
1698
|
+
.filter(v => v != null && v !== '');
|
|
1699
|
+
if (values.length === 0) return null;
|
|
1700
|
+
return { questionId: String(questionId), values, passengerIndex };
|
|
1701
|
+
})
|
|
1702
|
+
.filter(Boolean);
|
|
1703
|
+
|
|
1704
|
+
console.log('SACHIN participantAnswers: ', JSON.stringify(participantAnswers, null, 2));
|
|
1705
|
+
const passengerDetails = participantContactFields
|
|
1706
|
+
.map(entry => {
|
|
1707
|
+
const questionId = entry?.field?.id || entry?.field?.questionId;
|
|
1708
|
+
if (!questionId) return null;
|
|
1709
|
+
const raw = entry.value;
|
|
1710
|
+
const values = (Array.isArray(raw) ? raw : [raw])
|
|
1711
|
+
.map(v => normalizePassengerDetailValue({ questionId, value: v }))
|
|
1712
|
+
.filter(v => v != null && v !== '');
|
|
1713
|
+
if (values.length === 0) return null;
|
|
1714
|
+
return { questionId: String(questionId), values, passengerIndex };
|
|
1715
|
+
})
|
|
1716
|
+
.filter(Boolean);
|
|
1717
|
+
|
|
1718
|
+
console.log('SACHIN passengerDetails: ', JSON.stringify(passengerDetails, null, 2));
|
|
1719
|
+
// Indexed customFieldValues (field|N suffix) also scoped to this passenger
|
|
1720
|
+
const indexedAnswers = (passengerAnswersByIndex[passengerIndex] || [])
|
|
1721
|
+
.map(a => ({ ...a, passengerIndex }));
|
|
1722
|
+
|
|
1723
|
+
const allAnswers = [...itemAnswers, ...participantAnswers, ...indexedAnswers];
|
|
1724
|
+
|
|
1725
|
+
return {
|
|
1726
|
+
pricingCategoryId: item.unitId != null ? Number(item.unitId) : undefined,
|
|
1727
|
+
quantity: 1,
|
|
1728
|
+
...(allAnswers.length > 0 ? { answers: allAnswers } : {}),
|
|
1729
|
+
...(passengerDetails.length > 0 ? { passengerDetails } : {}),
|
|
1730
|
+
};
|
|
1731
|
+
}).filter(item => item.pricingCategoryId != null);
|
|
1445
1732
|
const activityBookingRequest = {
|
|
1446
1733
|
activityId: parsedActivityId,
|
|
1447
1734
|
...(parsedRateId != null ? { rateId: parsedRateId } : {}),
|
|
1448
1735
|
...(bookingDate ? { date: bookingDate } : {}),
|
|
1449
1736
|
...(parsedStartTimeId ? { startTimeId: parsedStartTimeId } : {}),
|
|
1737
|
+
...((normalizedActivityAnswers.length > 0 || bookingAnswers.length > 0)
|
|
1738
|
+
? { answers: [...normalizedActivityAnswers, ...bookingAnswers] }
|
|
1739
|
+
: {}),
|
|
1450
1740
|
passengers: passengersArray,
|
|
1451
1741
|
};
|
|
1452
|
-
|
|
1453
|
-
const firstNameValue = holder?.firstName || (holder?.name ? holder.name.split(' ')[0] : '') || '';
|
|
1454
|
-
const rawLastName = holder?.surname || (holder?.name ? holder.name.split(' ').slice(1).join(' ') : '') || '';
|
|
1455
|
-
const lastNameValue = rawLastName || firstNameValue || 'Guest';
|
|
1456
|
-
const activityEmailCheck = validateBookingEmail(holder?.email || holder?.emailAddress);
|
|
1457
|
-
if (!activityEmailCheck.ok) {
|
|
1458
|
-
throw new Error(activityEmailCheck.error);
|
|
1459
|
-
}
|
|
1460
|
-
const emailValue = activityEmailCheck.email;
|
|
1461
|
-
|
|
1462
|
-
const mainContactDetails = [
|
|
1463
|
-
{
|
|
1464
|
-
questionId: 'firstName',
|
|
1465
|
-
values: [firstNameValue],
|
|
1466
|
-
},
|
|
1467
|
-
{
|
|
1468
|
-
questionId: 'lastName',
|
|
1469
|
-
values: [lastNameValue],
|
|
1470
|
-
},
|
|
1471
|
-
{
|
|
1472
|
-
questionId: 'email',
|
|
1473
|
-
values: [emailValue],
|
|
1474
|
-
},
|
|
1475
|
-
];
|
|
1476
|
-
|
|
1477
1742
|
const directBooking = {
|
|
1478
1743
|
mainContactDetails,
|
|
1479
1744
|
activityBookings: [activityBookingRequest],
|
|
@@ -1485,11 +1750,53 @@ class Plugin {
|
|
|
1485
1750
|
paymentMethod,
|
|
1486
1751
|
source,
|
|
1487
1752
|
directBooking,
|
|
1488
|
-
sendNotificationToMainContact
|
|
1753
|
+
sendNotificationToMainContact,
|
|
1489
1754
|
showPricesInNotification: true,
|
|
1490
1755
|
currency: bookingCurrency,
|
|
1491
1756
|
};
|
|
1492
1757
|
|
|
1758
|
+
try {
|
|
1759
|
+
const optionsPath = `/checkout.json/options/booking-request?currency=${
|
|
1760
|
+
encodeURIComponent(bookingCurrency)
|
|
1761
|
+
}&lang=EN`;
|
|
1762
|
+
const {
|
|
1763
|
+
url: optionsUrl,
|
|
1764
|
+
pathForSigning: optionsPathForSigning,
|
|
1765
|
+
} = buildRequestUrlAndPath(baseUrl, optionsPath);
|
|
1766
|
+
const optionsHeaders = getHeaders({
|
|
1767
|
+
accessKey,
|
|
1768
|
+
secretKey,
|
|
1769
|
+
method: 'POST',
|
|
1770
|
+
path: optionsPathForSigning,
|
|
1771
|
+
});
|
|
1772
|
+
const optionsResponse = await axios({
|
|
1773
|
+
method: 'post',
|
|
1774
|
+
url: optionsUrl,
|
|
1775
|
+
headers: optionsHeaders,
|
|
1776
|
+
data: directBooking,
|
|
1777
|
+
});
|
|
1778
|
+
const optionsData = R.pathOr({}, ['data'], optionsResponse);
|
|
1779
|
+
const requiredQuestions = extractRequiredQuestions(optionsData);
|
|
1780
|
+
|
|
1781
|
+
const requiredFieldError = validateRequiredContactFields({
|
|
1782
|
+
requiredQuestions,
|
|
1783
|
+
mainContactDetails: directBooking.mainContactDetails,
|
|
1784
|
+
sendNotificationToMainContact,
|
|
1785
|
+
});
|
|
1786
|
+
if (requiredFieldError) {
|
|
1787
|
+
console.error('Error creating booking via checkout:', requiredFieldError);
|
|
1788
|
+
return { booking: null, error: requiredFieldError };
|
|
1789
|
+
}
|
|
1790
|
+
} catch (optionsErr) {
|
|
1791
|
+
// Best-effort: don't block booking creation if Bokun's options endpoint
|
|
1792
|
+
// is unavailable. Surface as a warning so it doesn't trip error alarms.
|
|
1793
|
+
console.warn(
|
|
1794
|
+
'Could not fetch required booking questions (status %s): %j',
|
|
1795
|
+
optionsErr.response?.status || 'n/a',
|
|
1796
|
+
optionsErr.response?.data || optionsErr.message,
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1493
1800
|
const submitPath = '/checkout.json/submit?lang=EN';
|
|
1494
1801
|
const { url: submitUrl, pathForSigning: submitPathForSigning } = buildRequestUrlAndPath(baseUrl, submitPath);
|
|
1495
1802
|
const submitHeaders = getHeaders({
|
|
@@ -1538,7 +1845,7 @@ class Plugin {
|
|
|
1538
1845
|
const bookingConfirmationBody = {
|
|
1539
1846
|
externalBookingReference: externalBookingReference || null,
|
|
1540
1847
|
showPricesInNotification: true,
|
|
1541
|
-
sendNotificationToMainContact
|
|
1848
|
+
sendNotificationToMainContact,
|
|
1542
1849
|
amount: amountForConfirmation,
|
|
1543
1850
|
currency: bookingCurrency,
|
|
1544
1851
|
};
|
|
@@ -1550,56 +1857,24 @@ class Plugin {
|
|
|
1550
1857
|
data: bookingConfirmationBody,
|
|
1551
1858
|
});
|
|
1552
1859
|
|
|
1553
|
-
return await toTranslatedBooking(
|
|
1860
|
+
return await toTranslatedBooking({
|
|
1861
|
+
bookingPayload: confirmResponse,
|
|
1862
|
+
bookingTypeDefs,
|
|
1863
|
+
bookingQuery,
|
|
1864
|
+
baseUrl,
|
|
1865
|
+
tenant: token.tenant,
|
|
1866
|
+
});
|
|
1554
1867
|
} catch (err) {
|
|
1555
1868
|
const details = err.response?.data || err.message;
|
|
1869
|
+
const errorMessage = extractBokunErrorMessage(err);
|
|
1556
1870
|
console.error('Error creating booking via checkout:', details);
|
|
1557
|
-
return {
|
|
1871
|
+
return {
|
|
1872
|
+
booking: null,
|
|
1873
|
+
error: errorMessage || (typeof details === 'string' ? details : JSON.stringify(details)),
|
|
1874
|
+
};
|
|
1558
1875
|
}
|
|
1559
1876
|
}
|
|
1560
1877
|
|
|
1561
|
-
// /**
|
|
1562
|
-
// * Get checkout options for a booking request.
|
|
1563
|
-
// * Wraps POST /checkout.json/options/booking-request
|
|
1564
|
-
// * Schema: body is BookingRequest, response is Checkout.
|
|
1565
|
-
// */
|
|
1566
|
-
// async checkoutOptionsForBookingRequest({
|
|
1567
|
-
// axios,
|
|
1568
|
-
// token,
|
|
1569
|
-
// payload = {},
|
|
1570
|
-
// }) {
|
|
1571
|
-
// const { endpoint, testMode } = token || {};
|
|
1572
|
-
// const { accessKey, secretKey } = this.getCredentials(token);
|
|
1573
|
-
// const baseUrl = testMode ? 'https://api.bokuntest.com' : (endpoint || this.endpoint || 'https://api.bokun.io');
|
|
1574
|
-
|
|
1575
|
-
// const { bookingRequest } = payload;
|
|
1576
|
-
// assert(bookingRequest, 'bookingRequest is required for checkoutOptionsForBookingRequest');
|
|
1577
|
-
|
|
1578
|
-
// const path = '/checkout.json/options/booking-request';
|
|
1579
|
-
// const { url, pathForSigning } = buildRequestUrlAndPath(baseUrl, path);
|
|
1580
|
-
// const headers = getHeaders({
|
|
1581
|
-
// accessKey,
|
|
1582
|
-
// secretKey,
|
|
1583
|
-
// method: 'POST',
|
|
1584
|
-
// path: pathForSigning,
|
|
1585
|
-
// });
|
|
1586
|
-
|
|
1587
|
-
// try {
|
|
1588
|
-
// const response = await axios({
|
|
1589
|
-
// method: 'post',
|
|
1590
|
-
// url,
|
|
1591
|
-
// headers,
|
|
1592
|
-
// data: bookingRequest,
|
|
1593
|
-
// });
|
|
1594
|
-
// const checkout = R.pathOr({}, ['data'], response);
|
|
1595
|
-
// return { checkout };
|
|
1596
|
-
// } catch (err) {
|
|
1597
|
-
// const details = err.response?.data || err.message;
|
|
1598
|
-
// console.error('Error fetching checkout options for booking request:', details);
|
|
1599
|
-
// return { checkout: null, error: typeof details === 'string' ? details : JSON.stringify(details) };
|
|
1600
|
-
// }
|
|
1601
|
-
// }
|
|
1602
|
-
|
|
1603
1878
|
async cancelBooking({
|
|
1604
1879
|
axios,
|
|
1605
1880
|
token,
|