@ordergroove/offers 2.26.11 → 2.27.1

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +34 -0
  3. package/build.js +3 -1
  4. package/dist/bundle-report.html +185 -116
  5. package/dist/offers.js +65 -76
  6. package/dist/offers.js.map +3 -3
  7. package/examples/cart.js +105 -0
  8. package/examples/index.html +2 -2
  9. package/examples/products/cheap-watch.js +183 -0
  10. package/examples/shopify-cart.html +26 -0
  11. package/examples/shopify-pdp.html +34 -0
  12. package/karma.conf.js +2 -1
  13. package/package.json +4 -4
  14. package/src/__tests__/offers.spec.js +35 -10
  15. package/src/components/FrequencyStatus.js +14 -11
  16. package/src/components/IncentiveText.js +2 -1
  17. package/src/components/Offer.js +14 -7
  18. package/src/components/OptinButton.js +1 -1
  19. package/src/components/OptinSelect.js +2 -2
  20. package/src/components/OptinToggle.js +2 -2
  21. package/src/components/OptoutButton.js +1 -1
  22. package/src/components/Price.js +7 -3
  23. package/src/components/Select.js +3 -13
  24. package/src/components/SelectFrequency.js +24 -6
  25. package/src/components/TestWizard.js +1 -1
  26. package/src/components/__tests__/OG.fspec.js +24 -0
  27. package/src/components/__tests__/Offer.spec.js +4 -4
  28. package/src/components/__tests__/OptinButton.spec.js +2 -2
  29. package/src/components/__tests__/OptinToggle.spec.js +2 -2
  30. package/src/components/__tests__/OptoutButton.spec.js +1 -1
  31. package/src/components/__tests__/SelectFrequency.fspec.js +1 -0
  32. package/src/components/__tests__/SelectFrequency.spec.js +1 -1
  33. package/src/components/__tests__/TestWizard.spec.js +2 -2
  34. package/src/components/__tests__/Text.spec.js +5 -1
  35. package/src/core/__tests__/actions.spec.js +6 -6
  36. package/src/core/actions.js +22 -17
  37. package/src/core/constants.js +21 -0
  38. package/src/core/descriptors.js +2 -1
  39. package/src/core/middleware.js +41 -1
  40. package/src/core/reducer.js +22 -21
  41. package/src/core/resolveProperties.js +2 -7
  42. package/src/core/selectors.js +1 -1
  43. package/src/core/store.js +17 -9
  44. package/src/core/utils.ts +67 -0
  45. package/src/index.js +46 -203
  46. package/src/make-api.js +195 -0
  47. package/src/platform.ts +9 -0
  48. package/src/shopify/__tests__/shopifyMiddleware.spec.js +126 -0
  49. package/src/shopify/__tests__/shopifyReducer.spec.js +489 -0
  50. package/src/shopify/shopifyBootstrap.ts +136 -0
  51. package/src/shopify/shopifyMiddleware.ts +336 -0
  52. package/src/shopify/shopifyReducer.js +254 -0
  53. package/tsconfig.json +35 -0
  54. package/examples/5starnutrition-main.js +0 -3
  55. package/examples/single-offer.html +0 -9
  56. package/src/init-test.js +0 -3
@@ -0,0 +1,136 @@
1
+ import { authorize } from '../core/actions';
2
+ import { clearCookie, getCookieValue, getMainJs, resolveEnvAndMerchant } from '../core/utils';
3
+
4
+ const SHOPIFY_OG_AUTH_ENDPOINT = '/apps/subscriptions/auth/';
5
+ const SHOPIFY_OG_AUTH_BEGIN = 'og_auth_begin';
6
+ const SHOPIFY_OG_AUTH_END = 'og_auth_end';
7
+
8
+ type ShopifyOGAuth = {
9
+ customerId: string;
10
+ timestamp: Number;
11
+ signature: string;
12
+ };
13
+
14
+ /**
15
+ * We tag shopify liquid this way.
16
+ * window.ogShopifyConfig = {
17
+ * {%- if customer -%}
18
+ * customer: {
19
+ * id: "{{customer.id}}",
20
+ * email: "{{customer.email}}",
21
+ * signature: "{{signature}}",
22
+ * timestamp: "{{timestamp}}",
23
+ * },
24
+ * {%- else -%}
25
+ * customer: null,
26
+ * {%- endif -%}
27
+ */
28
+ type OgShopifyConfigCustomer = {
29
+ id: string;
30
+ signature: string;
31
+ timestamp: string;
32
+ email?: string;
33
+ };
34
+
35
+ type OgShopifyConfig = {
36
+ customer?: OgShopifyConfigCustomer;
37
+ };
38
+
39
+ /**
40
+ * We rely on window.ogShopifyConfig side of integration
41
+ */
42
+ declare global {
43
+ interface Window {
44
+ og: {
45
+ previewMode: boolean;
46
+ };
47
+ ogShopifyConfig: OgShopifyConfig;
48
+ Shopify: { routes?: { root: String } };
49
+ }
50
+ }
51
+
52
+ const parseIntegrationTempAuth = (raw: string) => {
53
+ const [id, timestamp, signature, email] = atob(raw).split('|');
54
+ return {
55
+ id,
56
+ signature,
57
+ timestamp,
58
+ email
59
+ } as OgShopifyConfigCustomer;
60
+ };
61
+ /**
62
+ *
63
+ * Markup needed for integration:
64
+ * ```html
65
+ * <script src="https://static.ordergroove.com/<merchant_id>/main.js" {% if customer -%}
66
+ * {%- assign secret_key = shop.metafields.accentuate.theme_hash_key -%}
67
+ * {%- assign timestamp = "now" | date: "%s" -%}
68
+ * {%- assign signature = customer.id | append: "|" | append: timestamp | hmac_sha256: secret_key -%}
69
+ * data-customer="{{customer.id | append: "|" | append: timestamp | append: "|" | append: signature | append: "|" | append: customer.email | base64_encode }}"
70
+ * {%- endif %}></script>
71
+ * ```
72
+ */
73
+ export async function authorizeShopifyCustomer({ store }) {
74
+ const [merchantId] = resolveEnvAndMerchant();
75
+ const script = getMainJs();
76
+
77
+ let customer = script?.dataset.customer
78
+ ? parseIntegrationTempAuth(script.dataset.customer)
79
+ : window.ogShopifyConfig?.customer;
80
+ if (customer) {
81
+ const val = await getOrCreateAuthCookie(customer);
82
+ if (val) {
83
+ const [sig_field, ts, sig] = val.split('|');
84
+ store.dispatch(authorize(merchantId, sig_field, Number(ts), sig));
85
+ }
86
+ } else {
87
+ clearCookie('og_auth');
88
+ }
89
+ }
90
+ /**
91
+ * Borrow from here https://github.com/ordergroove/shopify-app/blob/88becb621b29776a946ab0cd3ae215043e174626/server/lib/theme-partials/js/helpers/cookies.js#L18
92
+ * @param customer
93
+ * @returns
94
+ */
95
+ export async function fetchOGSignature(customer: OgShopifyConfigCustomer) {
96
+ try {
97
+ const response = await fetch(
98
+ `${SHOPIFY_OG_AUTH_ENDPOINT}?customer=${customer.id}&customer_signature=${customer.signature}&customer_timestamp=${customer.timestamp}`
99
+ );
100
+ const data = await response.text();
101
+ const beginningIndex = data.lastIndexOf(SHOPIFY_OG_AUTH_BEGIN);
102
+
103
+ if (beginningIndex < 0) throw 'Invalid response from OG auth endpoint';
104
+
105
+ return JSON.parse(
106
+ data.substring(beginningIndex + SHOPIFY_OG_AUTH_BEGIN.length, data.lastIndexOf(SHOPIFY_OG_AUTH_END))
107
+ ) as ShopifyOGAuth;
108
+ } catch (err) {
109
+ console.error(err);
110
+ }
111
+ }
112
+ /**
113
+ * Original source https://github.com/ordergroove/shopify-app/blob/88becb621b29776a946ab0cd3ae215043e174626/server/lib/theme-partials/js/helpers/cookies.js#L56
114
+ *
115
+ * @param customer
116
+ * @returns
117
+ */
118
+ export async function getOrCreateAuthCookie(customer: OgShopifyConfigCustomer) {
119
+ const ogAuthCookie = getCookieValue('og_auth');
120
+
121
+ // The cookie hasn't expired yet so we don't need to refresh the auth
122
+ if (ogAuthCookie) {
123
+ return ogAuthCookie;
124
+ }
125
+ const { customerId, timestamp, signature } = await fetchOGSignature(customer);
126
+
127
+ if (!customerId) return '';
128
+
129
+ // set expiration to now + 2hrs
130
+ const ogToday = new Date();
131
+ const binarySignature = btoa(signature);
132
+ ogToday.setTime(ogToday.getTime() + 2 * 60 * 60 * 1000);
133
+ const value = `${customerId}|${timestamp}|${binarySignature};expires=${ogToday.toUTCString()}`;
134
+ document.cookie = `og_auth=${value};secure;path=/`;
135
+ return value;
136
+ }
@@ -0,0 +1,336 @@
1
+ import memoize from 'lodash.memoize';
2
+ import { debounce } from 'throttle-debounce';
3
+
4
+ import {
5
+ CART_UPDATED_EVENT,
6
+ OPTIN_PRODUCT,
7
+ OPTOUT_PRODUCT,
8
+ PRODUCT_CHANGE_FREQUENCY,
9
+ RECEIVE_OFFER,
10
+ REQUEST_OFFER,
11
+ SETUP_CART,
12
+ SETUP_PRODUCT
13
+ } from '../core/constants';
14
+
15
+ import { makeSubscribedSelector } from '../core/selectors';
16
+ import { safeProductId } from '../core/utils';
17
+
18
+ const SHOPIFY_ROOT = window.Shopify?.routes?.root || '/';
19
+ const CART_PAGE_URL = '/cart';
20
+ const CART_JS_URL = `${SHOPIFY_ROOT}cart.js`;
21
+ const PRODUCTS_URL = `${SHOPIFY_ROOT}products/`;
22
+
23
+ const syncProductId = debounce(100, false, function(form, offer) {
24
+ const { id } = Object.fromEntries([...new FormData(form).entries()]);
25
+ offer.setAttribute('product', id);
26
+ });
27
+
28
+ async function setupPdp(store, offer) {
29
+ const handle = guessProductHandle();
30
+ if (handle) {
31
+ try {
32
+ store.dispatch({ type: SETUP_PRODUCT, payload: await getProduct(handle) });
33
+ } catch (err) {
34
+ console.warn('OG: Unable to fetch product details for PDP', err);
35
+ }
36
+ }
37
+
38
+ const form = offer.closest('form');
39
+
40
+ // keep product.id form in sync with offer serving.
41
+ new MutationObserver(() => syncProductId(form, offer)).observe(form, { subtree: true, childList: true });
42
+ }
43
+
44
+ const getCart = async () => await (await fetch(CART_JS_URL)).json();
45
+
46
+ /**
47
+ * Attemps to guess the product handle o
48
+ * @returns
49
+ */
50
+ function guessProductHandle(): String {
51
+ return (
52
+ [
53
+ () =>
54
+ // Use the oembed to get the product handle
55
+ (document
56
+ .querySelector('[href$=".oembed"]')
57
+ ?.getAttribute('href')
58
+ ?.match(/\/([^\/]+)\.oembed$/) || [])[1],
59
+
60
+ () =>
61
+ // Use the open graph og:type==product and og:url to get the product handle
62
+ ((document.querySelector('meta[property="og:type"][content="product"]') &&
63
+ document
64
+ .querySelector('meta[property="og:url"][content]')
65
+ ?.getAttribute('content')
66
+ ?.match(/\/([^\/]+)$/)) ||
67
+ [])[1],
68
+
69
+ () =>
70
+ // use any json in the markup
71
+ [...document.querySelectorAll('[type$=json]')]
72
+ .map(it => JSON.parse(it.textContent || '{}'))
73
+ .find(it => it.handle && it.price)?.handle
74
+ ]
75
+ // returns the first truthy and prevent call next functions
76
+ .reduce((acc, cur) => acc || cur(), '')
77
+ );
78
+ }
79
+
80
+ const getProduct = memoize(async handle => (await fetch(`${PRODUCTS_URL}${handle}.js`)).json());
81
+
82
+ async function setupCart(store, offer) {
83
+ const cart = await getCart();
84
+ const { items } = cart;
85
+ store.dispatch({ type: SETUP_CART, payload: cart });
86
+
87
+ // some minicart templates does not contains line.key but contains line which corresponds to
88
+ // the index on the cart items (Vedge)
89
+
90
+ const productAsCartLine = Number(offer.product.id);
91
+ if (productAsCartLine <= items.length) {
92
+ offer.setAttribute('product', items[productAsCartLine - 1].key);
93
+ }
94
+
95
+ const products = await Promise.all(Array.from(new Set(items.map(({ handle }) => handle))).map(getProduct));
96
+ products.forEach(product => store.dispatch({ type: SETUP_PRODUCT, payload: product }));
97
+ }
98
+
99
+ /**
100
+ * Synchronizes the optins/optouts using shopify cart ajax api
101
+ *
102
+ * @param action
103
+ * @param store
104
+ */
105
+ export async function synchronizeCartOptin(action: any, store: any) {
106
+ const offerElement = action.payload.offer;
107
+ const selling_plan = action.payload.frequency || null;
108
+ const trackingEvent = getTrackingEvent(action);
109
+
110
+ if (!offerElement?.isCart) {
111
+ if (offerElement) {
112
+ updateTrackingInputs(offerElement.product.id, trackingEvent[0], trackingEvent[1]);
113
+ }
114
+ return;
115
+ }
116
+
117
+ try {
118
+ // disable the interactions on the offer since we need to process its side-effects first.
119
+ offerElement.style.pointerEvents = 'none';
120
+ offerElement.style.opacity = '.7';
121
+
122
+ const closestSection = offerElement.closest('.shopify-section');
123
+ const closestSectionId = (closestSection?.id.match(/^shopify-section-(.+)/) || [])[1];
124
+
125
+ const key = action.payload.product.id; // shopify cart.item.key
126
+ const cart = await getCart();
127
+ const offerIx = cart?.items?.findIndex(it => it.key === key); // cart.items[offerIx];
128
+ const item = cart.items[offerIx];
129
+ const qty = item.quantity;
130
+ const productId = safeProductId(key);
131
+
132
+ const res = await fetch('/cart/change.js', {
133
+ method: 'POST',
134
+ credentials: 'same-origin',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({
137
+ id: key,
138
+ quantity: qty,
139
+ attributes: Object.fromEntries([trackingEvent]),
140
+ properties: item.properties,
141
+ selling_plan: selling_plan || null,
142
+ sections: closestSectionId ? [closestSectionId] : undefined
143
+ })
144
+ });
145
+
146
+ if (res.status !== 200) throw new Error('Cart not updated');
147
+
148
+ const newCart = await res.json();
149
+
150
+ // If both carts have same length we can update the item.key
151
+ // to the original offer element, at least provide
152
+ // some graceful degradations if no sections nor cart page
153
+ const newKey =
154
+ cart.items.length === newCart.items.length
155
+ ? newCart.items[offerIx].key
156
+ : newCart.items.find(
157
+ line =>
158
+ line.quantity === qty &&
159
+ line.product_id === productId &&
160
+ ((!selling_plan && !line.selling_plan_allocation) ||
161
+ line?.selling_plan_allocation.selling_plan.id === selling_plan)
162
+ )?.key;
163
+
164
+ if (newKey) offerElement.setAttribute('product', newKey);
165
+
166
+ // dispatch SETUP_CART so offer does not flip the state
167
+ store.dispatch({ type: SETUP_CART, payload: newCart });
168
+
169
+ // Use a custom event to hook custom cart updates.
170
+ const cartUpdateEvent = new CustomEvent(CART_UPDATED_EVENT, { bubbles: true, cancelable: true });
171
+ offerElement.dispatchEvent(cartUpdateEvent);
172
+
173
+ // Let client uses preventDefault if they want to skip default logic after event.
174
+ if (cartUpdateEvent.defaultPrevented) return;
175
+
176
+ const sections = newCart.sections;
177
+
178
+ if (sections && closestSectionId in sections) {
179
+ const sectionRawHtml = sections[closestSectionId];
180
+
181
+ const el = new DOMParser()
182
+ .parseFromString(sectionRawHtml.toString() || '', 'text/html')
183
+ .getElementById('shopify-section-' + closestSectionId);
184
+ if (el) {
185
+ closestSection.innerHTML = el.innerHTML;
186
+ }
187
+ } else if (window.location.pathname.startsWith(CART_PAGE_URL)) {
188
+ // only do if we are on the cart page
189
+ window.location.reload();
190
+ }
191
+ } catch (err) {
192
+ console.log('OG Error updating cart', err);
193
+ } finally {
194
+ offerElement.style.pointerEvents = 'auto';
195
+ offerElement.style.opacity = '1';
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Returns a tracking event adhering to the below format:
201
+ *
202
+ * og__<ts in seconds>: "<product_id>,<action>,<location>,<selling_plan optional>"
203
+ *
204
+ * Examples:
205
+ * og__165653130: "offer_show,pdp,123456789"
206
+ * og__165653137: "optin,pdp,123,456"
207
+ * og__165653138: "optout,pdp,123,"
208
+ * og__165653139: "optin,cart,123,456",
209
+ *
210
+ * @param action a Redux action
211
+ * @return {Array} an array with positional values key, value
212
+ */
213
+ export function getTrackingEvent(action): Array<string> {
214
+ const product_id = action.payload.product.id;
215
+ if (!product_id) return [];
216
+ const key = `og__${Math.ceil(new Date().getTime() / 1000)}`;
217
+ const location = action.payload.offer?.location || '';
218
+ const value = [product_id, action.type.toLowerCase(), location];
219
+
220
+ switch (action.type) {
221
+ case REQUEST_OFFER:
222
+ case OPTOUT_PRODUCT:
223
+ value.push('');
224
+ break;
225
+ case OPTIN_PRODUCT:
226
+ case PRODUCT_CHANGE_FREQUENCY:
227
+ value.push(action.payload.frequency);
228
+ break;
229
+ default:
230
+ return []; // we dont track anything else
231
+ }
232
+
233
+ return [key, value.join(',')];
234
+ }
235
+
236
+ /**
237
+ * Creates or updates a hidden input used for tracking on non-cart pages
238
+ *
239
+ * @param product_id a product ID
240
+ * @param name an input name, og_<timestamp in seconds>
241
+ * @param value an input value, <tracking event>
242
+ * @return {undefined}
243
+ */
244
+ export function updateTrackingInputs(product_id: string, name: string, value: string) {
245
+ const store2FormElementSelector = `[name="id"][value="${product_id}"]`;
246
+ const store1FormElementSelector = `form[action="/cart/add"] option[value="${product_id}"]`;
247
+ if (!name) return;
248
+ let cartAddFormElements = document.querySelectorAll(store2FormElementSelector);
249
+ if (!cartAddFormElements.length) {
250
+ cartAddFormElements = document.querySelectorAll(store1FormElementSelector);
251
+ }
252
+ [...cartAddFormElements].forEach((cartAddFormElement: HTMLInputElement) => {
253
+ const parent = cartAddFormElement.form;
254
+
255
+ let input = parent?.querySelector(`[name="${name}"]`) as HTMLInputElement;
256
+ if (!input) {
257
+ input = document.createElement('input');
258
+ input.type = 'hidden';
259
+ input.name = `attributes[${name}]`;
260
+ parent?.appendChild(input);
261
+ }
262
+ input.value = value;
263
+ });
264
+ }
265
+
266
+ export function getOrCreateHidden(parent, name, value) {
267
+ let input = parent.querySelector(`[name="${name}"]`);
268
+ if (input && !value) {
269
+ input.remove();
270
+ return;
271
+ }
272
+ if (!input && value) {
273
+ input = document.createElement('input');
274
+ input.type = 'hidden';
275
+ input.name = name;
276
+ parent.appendChild(input);
277
+ }
278
+ if (input) {
279
+ input.value = value;
280
+ }
281
+ }
282
+ /**
283
+ * // update <input type="hidden" name="selling_plan"/> if available
284
+ *
285
+ * @param store
286
+ */
287
+ function synchronizeSellingPlan(store: any, offerElement?: HTMLElement) {
288
+ [...document.querySelectorAll('[name=id]')].forEach((productIdInput: HTMLInputElement) => {
289
+ const productId = productIdInput.value;
290
+
291
+ const subscribedSelector = makeSubscribedSelector({ id: productId });
292
+ const sellingPlanId = subscribedSelector(store.getState())?.frequency;
293
+
294
+ getOrCreateHidden(productIdInput.form, 'selling_plan', sellingPlanId);
295
+ if (offerElement) {
296
+ // use this to update the product attributes in future
297
+ }
298
+ });
299
+ }
300
+
301
+ export default function shopifyMiddleware(store) {
302
+ return next => action => {
303
+ /**
304
+ * This redux middleware will perform Shopify specific side-effects such as change
305
+ * the product selling plan when offer is cart
306
+ */
307
+ switch (action.type) {
308
+ case OPTIN_PRODUCT:
309
+ case OPTOUT_PRODUCT:
310
+ case PRODUCT_CHANGE_FREQUENCY:
311
+ break;
312
+ case REQUEST_OFFER:
313
+ if (action.payload.offer?.isCart) {
314
+ setupCart(store, action.payload.offer);
315
+ } else {
316
+ setupPdp(store, action.payload.offer);
317
+ }
318
+ default:
319
+ }
320
+
321
+ next(action);
322
+
323
+ switch (action.type) {
324
+ case OPTIN_PRODUCT:
325
+ case OPTOUT_PRODUCT:
326
+ case PRODUCT_CHANGE_FREQUENCY:
327
+ synchronizeCartOptin(action, store);
328
+ case REQUEST_OFFER:
329
+ case RECEIVE_OFFER:
330
+ case SETUP_PRODUCT:
331
+ synchronizeSellingPlan(store, action.payload.offer);
332
+ break;
333
+ default:
334
+ }
335
+ };
336
+ }