@thirstie/thirstieservices 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +41 -0
- package/dist/bundle.cjs +2851 -0
- package/dist/bundle.iife.js +1 -0
- package/dist/bundle.mjs +2848 -0
- package/package.json +37 -0
- package/rollup.config.mjs +28 -0
- package/src/geoservice/index.js +232 -0
- package/src/index.js +7 -0
- package/src/thirstieapi/index.js +331 -0
- package/src/thirstieapi/utils/apirequest.js +34 -0
- package/tests/env.json.tpl +5 -0
- package/tests/fixtures/catalog.json +757 -0
- package/tests/fixtures/catalog_productline_offerings.json +689 -0
- package/tests/fixtures/google_autocomplete_response.json +281 -0
- package/tests/fixtures/google_autocomplete_response_withzip.json +75 -0
- package/tests/fixtures/google_placeid_details.json +104 -0
- package/tests/fixtures/guest_user.json +20 -0
- package/tests/fixtures/session_anonymous.json +8 -0
- package/tests/fixtures/user_addressbook.json +22 -0
- package/tests/fixtures/user_guest.json +20 -0
- package/tests/fixtures/user_loggedin.json +23 -0
- package/tests/fixtures/user_wallet.json +194 -0
- package/tests/functional/apirequest.func.test.js +46 -0
- package/tests/functional/geoservice.func.test.js +53 -0
- package/tests/integration/thirstieapi.int.test.js +89 -0
- package/tests/unit/geoservice.unit.test.js +367 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thirstie/thirstieservices",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Service layer for Thirstie ecommerce API",
|
|
5
|
+
"author": "Thirstie, Inc. <technology@thirstie.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"source": "src/index.js",
|
|
9
|
+
"main": "dist/bundle.cjs",
|
|
10
|
+
"module": "dist/bundle.mjs",
|
|
11
|
+
"exports": {
|
|
12
|
+
"require": "./dist/bundle.cjs",
|
|
13
|
+
"import": "./dist/bundle.mjs"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "rollup -c -w",
|
|
17
|
+
"build": "rollup -c",
|
|
18
|
+
"lint": "eslint src/",
|
|
19
|
+
"test:int": "node --experimental-vm-modules ../../node_modules/.bin/jest int --no-cache",
|
|
20
|
+
"test:func": "node --experimental-vm-modules ../../node_modules/.bin/jest func --no-cache",
|
|
21
|
+
"test:coverage": "node --experimental-vm-modules ../../node_modules/.bin/jest unit --coverage",
|
|
22
|
+
"test": "concurrently \"npm run lint\" \"npm run test:coverage\"",
|
|
23
|
+
"test:watch": "node --experimental-vm-modules ../../node_modules/.bin/jest unit --watch"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"ramda": "^0.29.1"
|
|
27
|
+
},
|
|
28
|
+
"jest": {
|
|
29
|
+
"moduleFileExtensions": [
|
|
30
|
+
"js",
|
|
31
|
+
"json"
|
|
32
|
+
],
|
|
33
|
+
"testEnvironment": "jsdom",
|
|
34
|
+
"transform": {}
|
|
35
|
+
},
|
|
36
|
+
"gitHead": "3e6f54e763d04240e244a4c085488c2b4a61ff4b"
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import resolve from '@rollup/plugin-node-resolve';
|
|
2
|
+
import terser from '@rollup/plugin-terser';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
input: 'src/index.js',
|
|
6
|
+
output: [
|
|
7
|
+
{
|
|
8
|
+
file: 'dist/bundle.mjs',
|
|
9
|
+
format: 'esm'
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
file: 'dist/bundle.cjs',
|
|
13
|
+
format: 'cjs'
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
file: 'dist/bundle.iife.js',
|
|
17
|
+
format: 'iife',
|
|
18
|
+
name: 'thirstieclient',
|
|
19
|
+
plugins: [ terser() ]
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
onwarn (warning, warn) {
|
|
23
|
+
// suppress meaningless warning, see: https://github.com/reduxjs/redux-toolkit/issues/1466
|
|
24
|
+
if (warning.code === 'THIS_IS_UNDEFINED') return;
|
|
25
|
+
warn(warning);
|
|
26
|
+
},
|
|
27
|
+
plugins: [ resolve() ]
|
|
28
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import * as R from 'ramda';
|
|
2
|
+
|
|
3
|
+
// helper functions
|
|
4
|
+
const findValueBy = R.curry((src, filterKey, searchStr, key) => {
|
|
5
|
+
const found = (obj) => R.includes(searchStr, obj[filterKey]);
|
|
6
|
+
return R.compose(R.prop(key), R.find(found))(src);
|
|
7
|
+
});
|
|
8
|
+
const addressLens = R.lensPath([ 'results', '0', 'address_components' ]);
|
|
9
|
+
|
|
10
|
+
export const transformPredictionResponse = (predictions, options = {}) => {
|
|
11
|
+
const { requestCity, requestState, requestZipcode } = options;
|
|
12
|
+
|
|
13
|
+
const extractPrediction = (prediction) => {
|
|
14
|
+
return { description: prediction.description, placeId: prediction.place_id };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let scoredPredictions = null;
|
|
18
|
+
if (!(requestCity || requestState || requestZipcode)) {
|
|
19
|
+
scoredPredictions = predictions.map(extractPrediction);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
scoredPredictions = predictions.map((row) => {
|
|
23
|
+
const result = {
|
|
24
|
+
description: row.description,
|
|
25
|
+
place_id: row.place_id
|
|
26
|
+
};
|
|
27
|
+
const cityMatch = row.terms.filter((term) => requestCity && term.value === requestCity).length > 0 ? 1 : 0;
|
|
28
|
+
const stateMatch = row.terms.filter((term) => requestState && term.value === requestState).length > 0 ? 1 : 0;
|
|
29
|
+
const zipMatch = row.terms.filter((term) => requestZipcode && term.value === requestZipcode).length > 0 ? 1 : 0;
|
|
30
|
+
|
|
31
|
+
result.score = cityMatch + stateMatch + zipMatch;
|
|
32
|
+
result.stateMatch = !requestState || (requestState && stateMatch);
|
|
33
|
+
result.zipCodeMatch = !requestZipcode || (requestZipcode && zipMatch);
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
}).filter(
|
|
37
|
+
rec => !!rec.stateMatch && !!rec.zipCodeMatch
|
|
38
|
+
).sort((a, b) => {
|
|
39
|
+
if (a.score === b.score) {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
return a.score < b.score ? 1 : -1;
|
|
43
|
+
}).map(extractPrediction);
|
|
44
|
+
|
|
45
|
+
return scoredPredictions;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const parsePlacesAddress = (res) => {
|
|
49
|
+
const placeResult = res.results ? res.results[0] : res.result;
|
|
50
|
+
if (!placeResult || R.isEmpty(placeResult)) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let addressComponents = null;
|
|
55
|
+
if (res.results) {
|
|
56
|
+
addressComponents = R.view(addressLens)(res);
|
|
57
|
+
} else {
|
|
58
|
+
addressComponents = placeResult.address_components;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const findAddressComponentByType = findValueBy(addressComponents, 'types');
|
|
62
|
+
|
|
63
|
+
const formattedAddress = placeResult.formatted_address;
|
|
64
|
+
const latitude =
|
|
65
|
+
placeResult.geometry &&
|
|
66
|
+
placeResult.geometry.location &&
|
|
67
|
+
placeResult.geometry.location.lat
|
|
68
|
+
? placeResult.geometry.location.lat
|
|
69
|
+
: null;
|
|
70
|
+
const longitude =
|
|
71
|
+
placeResult.geometry &&
|
|
72
|
+
placeResult.geometry.location &&
|
|
73
|
+
placeResult.geometry.location.lng
|
|
74
|
+
? placeResult.geometry.location.lng
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
const streetNumber = findAddressComponentByType('street_number', 'short_name') || '';
|
|
78
|
+
const streetRoute = findAddressComponentByType('route', 'long_name') || '';
|
|
79
|
+
const street1 =
|
|
80
|
+
(streetNumber || streetRoute) && `${streetNumber} ${streetRoute}`;
|
|
81
|
+
|
|
82
|
+
const address = {
|
|
83
|
+
state: findAddressComponentByType('administrative_area_level_1', 'short_name'),
|
|
84
|
+
city:
|
|
85
|
+
findAddressComponentByType('locality', 'short_name') ||
|
|
86
|
+
findAddressComponentByType('sublocality', 'short_name') ||
|
|
87
|
+
findAddressComponentByType('neighborhood', 'long_name') ||
|
|
88
|
+
findAddressComponentByType('administrative_area_level_3', 'long_name') ||
|
|
89
|
+
findAddressComponentByType('administrative_area_level_2', 'short_name'),
|
|
90
|
+
street_1: street1.trim(),
|
|
91
|
+
country: findAddressComponentByType('country', 'short_name'),
|
|
92
|
+
zipcode: findAddressComponentByType('postal_code', 'long_name'),
|
|
93
|
+
latitude: latitude && Number(latitude),
|
|
94
|
+
longitude: longitude && Number(longitude),
|
|
95
|
+
formattedAddress
|
|
96
|
+
};
|
|
97
|
+
return R.filter(Boolean)(address);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
class GeoService {
|
|
101
|
+
constructor (mapsKey) {
|
|
102
|
+
this.mapsKey = mapsKey;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async geocode (address) {
|
|
106
|
+
const res = await fetch(
|
|
107
|
+
`https://maps.googleapis.com/maps/api/geocode/json?address=${address}&key=${this.mapsKey}`
|
|
108
|
+
);
|
|
109
|
+
const response = await res.json();
|
|
110
|
+
return parsePlacesAddress(response);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async geocodeZip (zipCode) {
|
|
114
|
+
const res = await fetch(
|
|
115
|
+
`https://maps.googleapis.com/maps/api/geocode/json?components=country:US|postal_code:${zipCode}&key=${this.mapsKey}`
|
|
116
|
+
);
|
|
117
|
+
const response = await res.json();
|
|
118
|
+
return parsePlacesAddress(response);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async reverseGeocode (lat, lng) {
|
|
122
|
+
const res = await fetch(
|
|
123
|
+
`https://maps.googleapis.com/maps/api/geocode/json?latlng=${lat},${lng}&key=${this.mapsKey}`
|
|
124
|
+
);
|
|
125
|
+
const response = await res.json();
|
|
126
|
+
return parsePlacesAddress(response);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async geoLocate () {
|
|
130
|
+
// https://developers.google.com/maps/documentation/geolocation/overview
|
|
131
|
+
// NOTE: also returns accuracy (95% confidence of lat, lng in meters)
|
|
132
|
+
const url = `https://www.googleapis.com/geolocation/v1/geolocate?key=${this.mapsKey}`;
|
|
133
|
+
const requestConfig = {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
Accept: 'application/json',
|
|
137
|
+
'Content-Type': 'application/json'
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const request = await fetch(url, requestConfig);
|
|
141
|
+
const response = await request.json();
|
|
142
|
+
const { lat, lng } = response.location;
|
|
143
|
+
return this.reverseGeocode(lat, lng);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getLocationSuggestions (payload) {
|
|
147
|
+
/*
|
|
148
|
+
https://developers.google.com/maps/documentation/places/web-service/autocomplete
|
|
149
|
+
Component should use session token: https://developers.google.com/maps/documentation/places/web-service/autocomplete#sessiontoken
|
|
150
|
+
**/
|
|
151
|
+
const { input, latitude, longitude, radius } = payload;
|
|
152
|
+
const { requestCity, requestState, requestZipcode } = payload;
|
|
153
|
+
const location = R.join(',', R.filter(Boolean, [ latitude, longitude ]));
|
|
154
|
+
|
|
155
|
+
let { sessiontoken } = payload;
|
|
156
|
+
if (!sessiontoken) {
|
|
157
|
+
sessiontoken = crypto.randomUUID().replaceAll('-', '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const params = R.filter(Boolean, {
|
|
161
|
+
input,
|
|
162
|
+
sessiontoken,
|
|
163
|
+
location, // The point around which to retrieve place information.
|
|
164
|
+
radius, // The radius parameter must also be provided when specifying a location
|
|
165
|
+
components: 'country:US',
|
|
166
|
+
types: 'address',
|
|
167
|
+
key: this.mapsKey
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
let queryString = '';
|
|
171
|
+
if (params) {
|
|
172
|
+
const searchParams = new URLSearchParams(params);
|
|
173
|
+
queryString = searchParams.toString();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const url = `https://maps.googleapis.com/maps/api/place/autocomplete/json?${queryString}`;
|
|
177
|
+
const requestConfig = {
|
|
178
|
+
method: 'GET',
|
|
179
|
+
headers: {
|
|
180
|
+
Accept: 'application/json'
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
const request = await fetch(url, requestConfig);
|
|
184
|
+
const response = await request.json();
|
|
185
|
+
|
|
186
|
+
let autocompletePredictions = [];
|
|
187
|
+
if (response.predictions && response.status === 'OK') {
|
|
188
|
+
const options = { requestCity, requestState, requestZipcode };
|
|
189
|
+
autocompletePredictions = transformPredictionResponse(response.predictions, options);
|
|
190
|
+
}
|
|
191
|
+
return { autocompletePredictions, sessiontoken };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async getPlaceId (payload) {
|
|
195
|
+
const { placeId, sessiontoken } = payload;
|
|
196
|
+
|
|
197
|
+
const params = R.filter(Boolean, {
|
|
198
|
+
place_id: placeId,
|
|
199
|
+
sessiontoken,
|
|
200
|
+
key: this.mapsKey
|
|
201
|
+
});
|
|
202
|
+
let queryString = '';
|
|
203
|
+
if (params) {
|
|
204
|
+
const searchParams = new URLSearchParams(params);
|
|
205
|
+
queryString = searchParams.toString();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const url = `https://maps.googleapis.com/maps/api/place/details/json?${queryString}`;
|
|
209
|
+
const res = await fetch(url);
|
|
210
|
+
const response = await res.json();
|
|
211
|
+
return parsePlacesAddress(response);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async getNavigatorPosition () {
|
|
215
|
+
// check if navigator is available
|
|
216
|
+
// NOTE: also returns accuracy (95% confidence of lat, lng in meters)
|
|
217
|
+
// TODO: check accuracy and only return if within acceptable limit, provided in payload
|
|
218
|
+
const handleError = (error) => {
|
|
219
|
+
console.error(error);
|
|
220
|
+
};
|
|
221
|
+
(navigator.geolocation && navigator.geolocation.getCurrentPosition(
|
|
222
|
+
(position) => {
|
|
223
|
+
const lat = position.coords.latitude;
|
|
224
|
+
const lng = position.coords.longitude;
|
|
225
|
+
return this.reverseGeocode(lat, lng);
|
|
226
|
+
},
|
|
227
|
+
handleError
|
|
228
|
+
)) || handleError({ message: 'geolocation is not enabled.' });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export default GeoService;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import apiRequest from './utils/apirequest.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Access Thirstie API and manage session information.
|
|
5
|
+
*/
|
|
6
|
+
export default class ThirstieAPI {
|
|
7
|
+
// private fields
|
|
8
|
+
#apiKey;
|
|
9
|
+
#thirstieApiBaseUrl;
|
|
10
|
+
#_apiState;
|
|
11
|
+
#emptyState;
|
|
12
|
+
#environment;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Initialize Thirstie API with public api key
|
|
16
|
+
* @param {string} apiKey - The public basic auth key, defines application access
|
|
17
|
+
* @param {Object=} config - Optional config settings
|
|
18
|
+
* @param {Object} config.env - Set to 'prod' to use production environment
|
|
19
|
+
* @param {Object} config.initState - Values to initialize session state
|
|
20
|
+
*/
|
|
21
|
+
constructor (apiKey, config = {}) {
|
|
22
|
+
const { env, initState } = config;
|
|
23
|
+
this.#environment = env;
|
|
24
|
+
this.#emptyState = {
|
|
25
|
+
sessionToken: null,
|
|
26
|
+
application: null,
|
|
27
|
+
applicationRef: null,
|
|
28
|
+
sessionRef: null,
|
|
29
|
+
userRef: null,
|
|
30
|
+
user: {},
|
|
31
|
+
message: null
|
|
32
|
+
};
|
|
33
|
+
this.#apiKey = apiKey;
|
|
34
|
+
this.#thirstieApiBaseUrl =
|
|
35
|
+
env === 'prod'
|
|
36
|
+
? 'https://api.thirstie.com'
|
|
37
|
+
: 'https://api.next.thirstie.com';
|
|
38
|
+
|
|
39
|
+
this.#_apiState = Object.seal(this.#emptyState);
|
|
40
|
+
const { sessionToken, application, applicationRef, sessionRef, userRef } = initState || {};
|
|
41
|
+
if (sessionToken || application || applicationRef || sessionRef || userRef) {
|
|
42
|
+
const apiInitState = { sessionToken, application, applicationRef, sessionRef, userRef };
|
|
43
|
+
this.apiState = apiInitState;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get sessionToken () {
|
|
48
|
+
return this.#_apiState.sessionToken;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get sessionRef () {
|
|
52
|
+
return this.#_apiState.sessionRef;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get userRef () {
|
|
56
|
+
return this.#_apiState.userRef;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get applicationRef () {
|
|
60
|
+
return this.#_apiState.applicationRef;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get application () {
|
|
64
|
+
return this.#_apiState.application;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Getter for session state
|
|
69
|
+
* This is safe to use for persisting state in localstorage
|
|
70
|
+
* because it does not contain user information.
|
|
71
|
+
*/
|
|
72
|
+
get sessionState () {
|
|
73
|
+
const {
|
|
74
|
+
sessionToken, application, applicationRef, sessionRef, userRef
|
|
75
|
+
} = this.#_apiState;
|
|
76
|
+
return { sessionToken, application, applicationRef, sessionRef, userRef };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Getter for general api state, contains user information
|
|
81
|
+
* Only use for internal comparison.
|
|
82
|
+
*/
|
|
83
|
+
get apiState () {
|
|
84
|
+
return this.#_apiState;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
set apiState (props) {
|
|
88
|
+
try {
|
|
89
|
+
return Object.assign(this.#_apiState, props);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('set apiState', error);
|
|
92
|
+
return this.#_apiState;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#handleSession (data) {
|
|
97
|
+
const sessionData = {
|
|
98
|
+
application: data.application_name,
|
|
99
|
+
applicationRef: data.uuid,
|
|
100
|
+
sessionToken: data.token,
|
|
101
|
+
sessionRef: data.session_uuid
|
|
102
|
+
};
|
|
103
|
+
if (data.user) {
|
|
104
|
+
sessionData.userRef = data.user.id;
|
|
105
|
+
const {
|
|
106
|
+
birthday, email, prefix, guest,
|
|
107
|
+
first_name: firstName, last_name: lastName, phone_number: phoneNumber,
|
|
108
|
+
last_login: lastLogin
|
|
109
|
+
} = data.user;
|
|
110
|
+
sessionData.user = {
|
|
111
|
+
email, birthday, prefix, firstName, lastName, phoneNumber, guest, lastLogin
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
this.apiState = sessionData;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#handleSessionError (err) {
|
|
118
|
+
this.apiState = this.#emptyState;
|
|
119
|
+
console.log('session error', err);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create a new session and retrieve a bearer token.
|
|
124
|
+
*
|
|
125
|
+
* Defaults to creating an anonymous session. If userCredentials are
|
|
126
|
+
* provided, then a user session will be returned.
|
|
127
|
+
*
|
|
128
|
+
* @param {Object=} userCredentials - user email & password
|
|
129
|
+
* @param {string} userCredentials.email
|
|
130
|
+
* @param {string} userCredentials.password
|
|
131
|
+
* @param {boolean} [basicAuth=true] - force use of application basic auth
|
|
132
|
+
* @returns {response}
|
|
133
|
+
*/
|
|
134
|
+
async getNewSession (userCredentials = {}, basicAuth = true) {
|
|
135
|
+
const { email, password } = userCredentials;
|
|
136
|
+
const options = { basicAuth };
|
|
137
|
+
if (email && password) {
|
|
138
|
+
options.data = { email, password };
|
|
139
|
+
}
|
|
140
|
+
const url = '/a/v2/sessions';
|
|
141
|
+
const response = await this.apiCaller('POST', url, options);
|
|
142
|
+
if (response && response.ok && response.data) {
|
|
143
|
+
return response.data;
|
|
144
|
+
} else {
|
|
145
|
+
return { code: response.status, message: response.message || 'unknown error', response };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate an existing session
|
|
151
|
+
* @param {string} sessionToken - Session JWT to validate
|
|
152
|
+
* @returns {object} session response
|
|
153
|
+
*/
|
|
154
|
+
async validateSession (sessionToken) {
|
|
155
|
+
if (sessionToken) {
|
|
156
|
+
this.apiState = { sessionToken };
|
|
157
|
+
}
|
|
158
|
+
if (!this.sessionToken) {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const url = '/a/v2/sessions';
|
|
163
|
+
const response = await this.apiCaller('GET', url);
|
|
164
|
+
if (response && response.ok && response.data) {
|
|
165
|
+
return response.data;
|
|
166
|
+
} else {
|
|
167
|
+
return await this.getNewSession();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create new session, or validates existing session and set apiState
|
|
173
|
+
* @param {string} [existingToken] If provided, session JWT to be validated
|
|
174
|
+
* @returns {object} API State
|
|
175
|
+
*/
|
|
176
|
+
async fetchSession (existingToken = null) {
|
|
177
|
+
let sessionData = {};
|
|
178
|
+
if (!this.sessionToken && !existingToken) {
|
|
179
|
+
sessionData = await this.getNewSession();
|
|
180
|
+
} else {
|
|
181
|
+
sessionData = await this.validateSession(existingToken);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (sessionData.token) {
|
|
185
|
+
this.#handleSession(sessionData);
|
|
186
|
+
} else {
|
|
187
|
+
this.#handleSessionError(sessionData);
|
|
188
|
+
}
|
|
189
|
+
return this.apiState;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Convert an anonymous session to a user session
|
|
194
|
+
* @param {object} userCredentials - { email, password }
|
|
195
|
+
* @returns {object} - api state
|
|
196
|
+
*/
|
|
197
|
+
async loginUser (userCredentials) {
|
|
198
|
+
const options = {};
|
|
199
|
+
const { email, password } = userCredentials;
|
|
200
|
+
|
|
201
|
+
// force use of existing anonymous session token if a token exists
|
|
202
|
+
const basicAuth = !this.sessionToken;
|
|
203
|
+
if (email && password) {
|
|
204
|
+
options.data = { email, password };
|
|
205
|
+
} else {
|
|
206
|
+
throw new Error('Invalid credential payload');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const responseData = await this.getNewSession(userCredentials, basicAuth);
|
|
210
|
+
if (responseData.token) {
|
|
211
|
+
this.#handleSession(responseData);
|
|
212
|
+
} else {
|
|
213
|
+
this.#handleSessionError(responseData);
|
|
214
|
+
const { response } = responseData;
|
|
215
|
+
const { data } = response;
|
|
216
|
+
if (data?.message) {
|
|
217
|
+
this.apiState.message = data?.message;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return this.apiState;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create a new user
|
|
225
|
+
* @param {object} userData - { email, password, birthday, prefix, firstName, lastName, phoneNumber, guestCheck }
|
|
226
|
+
* @param {Function} [errorHandler] - optional error callback
|
|
227
|
+
* @returns {object} - api state
|
|
228
|
+
*/
|
|
229
|
+
async createUser (userData, errorHandler = null) {
|
|
230
|
+
const options = {};
|
|
231
|
+
const { email, password } = userData;
|
|
232
|
+
const { birthday, prefix, firstName, lastName, phoneNumber, guestCheck, emailOptIn } = userData;
|
|
233
|
+
const requestPayload = {
|
|
234
|
+
email,
|
|
235
|
+
birthday,
|
|
236
|
+
prefix,
|
|
237
|
+
first_name: firstName,
|
|
238
|
+
last_name: lastName,
|
|
239
|
+
phone_number: phoneNumber,
|
|
240
|
+
guest_check: !!guestCheck // "allow guest checkout for existing user" if set to True
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (emailOptIn) {
|
|
244
|
+
requestPayload.aux_data = {
|
|
245
|
+
thirstieaccess_email_opt_in: emailOptIn
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// force use of existing anonymous session token if a token exists
|
|
250
|
+
const basicAuth = !this.sessionToken;
|
|
251
|
+
if (email && password) {
|
|
252
|
+
requestPayload.password = password;
|
|
253
|
+
} else {
|
|
254
|
+
requestPayload.guest = true;
|
|
255
|
+
}
|
|
256
|
+
options.data = requestPayload;
|
|
257
|
+
options.basicAuth = basicAuth;
|
|
258
|
+
|
|
259
|
+
const responseData = await this.apiCaller('POST', '/a/v2/users', options, errorHandler);
|
|
260
|
+
if (responseData.data.token) {
|
|
261
|
+
this.#handleSession(responseData.data);
|
|
262
|
+
} else {
|
|
263
|
+
this.#handleSessionError(responseData);
|
|
264
|
+
}
|
|
265
|
+
return this.apiState;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Invoke Thirstie API Endpoint
|
|
270
|
+
* @param {string} method - 'GET', 'POST', 'PUT', 'PATCH', 'DELETE'
|
|
271
|
+
* @param {string} url - api endpoint url
|
|
272
|
+
* @param {object} [options] - { data, params, basicAuth }
|
|
273
|
+
* @param {callback} [errorHandler] - error callback
|
|
274
|
+
* @returns {object} - api response
|
|
275
|
+
*/
|
|
276
|
+
async apiCaller (method, url, options = {}, errorHandler = null) {
|
|
277
|
+
const { data, params, basicAuth } = options;
|
|
278
|
+
const { sessionRef, application, applicationRef, userRef } = this.apiState;
|
|
279
|
+
|
|
280
|
+
if (!this.sessionToken && !basicAuth) {
|
|
281
|
+
throw new Error('Invalid Authorization');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const authHeader = (this.sessionToken && !basicAuth)
|
|
285
|
+
? `Bearer ${this.sessionToken}`
|
|
286
|
+
: `Basic ${btoa(this.#apiKey)}`;
|
|
287
|
+
|
|
288
|
+
const requestConfig = {
|
|
289
|
+
method,
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: authHeader,
|
|
292
|
+
Accept: 'application/json',
|
|
293
|
+
'Content-Type': 'application/json'
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
if ([ 'POST', 'PUT', 'PATCH' ].includes(method) && !!data) {
|
|
297
|
+
requestConfig.body = JSON.stringify(data);
|
|
298
|
+
}
|
|
299
|
+
let queryString = '';
|
|
300
|
+
if (params) {
|
|
301
|
+
const searchParams = new URLSearchParams(params);
|
|
302
|
+
queryString = searchParams.toString();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const telemetryContext = {
|
|
306
|
+
environment: this.#environment,
|
|
307
|
+
sessionRef,
|
|
308
|
+
application,
|
|
309
|
+
applicationRef,
|
|
310
|
+
userRef,
|
|
311
|
+
data,
|
|
312
|
+
queryString,
|
|
313
|
+
url
|
|
314
|
+
};
|
|
315
|
+
const requestUrl = `${this.#thirstieApiBaseUrl}${url}${queryString}`;
|
|
316
|
+
try {
|
|
317
|
+
const response = await apiRequest(requestUrl, requestConfig);
|
|
318
|
+
if (response) {
|
|
319
|
+
if (!response.ok && errorHandler) {
|
|
320
|
+
errorHandler({ code: response.status, message: response.statusText || 'unknown error', response, telemetryContext });
|
|
321
|
+
}
|
|
322
|
+
return response;
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (errorHandler) {
|
|
326
|
+
errorHandler({ code: 500, message: 'unknown error', error, telemetryContext });
|
|
327
|
+
}
|
|
328
|
+
return error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export default async function apiRequest (url, requestConfig) {
|
|
2
|
+
const apiResponse = {
|
|
3
|
+
ok: null,
|
|
4
|
+
status: null,
|
|
5
|
+
statusText: null,
|
|
6
|
+
data: {}
|
|
7
|
+
};
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(url, requestConfig);
|
|
10
|
+
|
|
11
|
+
let responseBody = {};
|
|
12
|
+
const headers = response.headers;
|
|
13
|
+
const hasBody = headers && headers.get('content-type')?.indexOf('application/json') > -1 && parseInt(headers.get('content-length')) > 0;
|
|
14
|
+
if (hasBody) {
|
|
15
|
+
responseBody = await response.json();
|
|
16
|
+
}
|
|
17
|
+
return Object.assign(apiResponse, {
|
|
18
|
+
ok: response.ok,
|
|
19
|
+
status: response.status,
|
|
20
|
+
statusText: response.statusText,
|
|
21
|
+
data: responseBody,
|
|
22
|
+
url
|
|
23
|
+
});
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error('apiRequest error: ', error);
|
|
26
|
+
return Object.assign(apiResponse, {
|
|
27
|
+
ok: false,
|
|
28
|
+
status: 500,
|
|
29
|
+
statusText: 'ERROR',
|
|
30
|
+
data: { message: 'Network error', error },
|
|
31
|
+
url
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|