merchi_cart 1.4.5 → 1.4.6

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.
@@ -32,6 +32,7 @@ import { makeProduct } from './utilities/product';
32
32
  import { getSavedCheckoutCustomer, saveCheckoutAddress } from './utilities/local_storage';
33
33
  import { makeUser } from './utilities/user';
34
34
  import { persistTestCheckoutFlag, readTestCheckoutFlag } from './utilities/test_checkout';
35
+ import { appendJobSourceToFormData, captureAndReadJobSource, cartNeedsSourceUpdate, jobSourceFieldsForApi } from './utilities/job_source';
35
36
  import { jsx as _jsx } from "react/jsx-runtime";
36
37
  var CartContext = /*#__PURE__*/createContext({
37
38
  cart: {},
@@ -500,66 +501,57 @@ var CartProvider = function CartProvider(_ref) {
500
501
  }
501
502
  function _createCartAndCookie() {
502
503
  _createCartAndCookie = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(callback) {
503
- var isTest, _cart, created, _cartJson;
504
+ var isTest, jobSource, _cart, query, created, _cartJson;
504
505
  return _regeneratorRuntime().wrap(function _callee2$(_context2) {
505
506
  while (1) switch (_context2.prev = _context2.next) {
506
507
  case 0:
507
508
  setFetchingCart(true);
508
509
  _context2.prev = 1;
509
510
  isTest = readTestCheckoutFlag();
511
+ jobSource = jobSourceFieldsForApi(captureAndReadJobSource());
510
512
  _cart = makeCart({
511
513
  domain: {
512
514
  id: domainId
513
515
  },
514
516
  isTest: isTest
515
517
  }, true);
516
- if (!isTest) {
517
- _context2.next = 11;
518
- break;
519
- }
520
- _context2.next = 7;
518
+ query = [['embed', JSON.stringify(cartEmbed)], ['skip_rights', 'y']];
519
+ if (isTest) query.push(['is_test', 'true']);
520
+ _context2.next = 9;
521
521
  return merchi.authenticatedFetch('/carts/', {
522
522
  method: 'POST',
523
- body: _cart.toFormData(),
524
- query: [['embed', JSON.stringify(cartEmbed)], ['skip_rights', 'y'], ['is_test', 'true']]
523
+ body: appendJobSourceToFormData(_cart.toFormData(), jobSource),
524
+ query: query
525
525
  });
526
- case 7:
526
+ case 9:
527
527
  created = _context2.sent;
528
528
  _cart.fromJson(created.cart);
529
529
  _context2.next = 13;
530
- break;
531
- case 11:
532
- _context2.next = 13;
533
- return _cart.create({
534
- embed: cartEmbed
535
- });
536
- case 13:
537
- _context2.next = 15;
538
530
  return _cart.toJson();
539
- case 15:
531
+ case 13:
540
532
  _cartJson = _context2.sent;
541
533
  if (domainId) setCartCookie(Number(domainId), _cartJson, undefined, isTest);
542
- _context2.next = 19;
534
+ _context2.next = 17;
543
535
  return attachSavedClientIfNeeded(_cartJson);
544
- case 19:
536
+ case 17:
545
537
  _cartJson = _context2.sent;
546
538
  setCart(_objectSpread({}, _cartJson));
547
539
  if (callback) callback();
548
- _context2.next = 27;
540
+ _context2.next = 25;
549
541
  break;
550
- case 24:
551
- _context2.prev = 24;
542
+ case 22:
543
+ _context2.prev = 22;
552
544
  _context2.t0 = _context2["catch"](1);
553
545
  alertError(_context2.t0.errorMessage || _context2.t0.message || 'Unable to fetch cart.');
554
- case 27:
555
- _context2.prev = 27;
546
+ case 25:
547
+ _context2.prev = 25;
556
548
  setFetchingCart(false);
557
- return _context2.finish(27);
558
- case 30:
549
+ return _context2.finish(25);
550
+ case 28:
559
551
  case "end":
560
552
  return _context2.stop();
561
553
  }
562
- }, _callee2, null, [[1, 24, 27, 30]]);
554
+ }, _callee2, null, [[1, 22, 25, 28]]);
563
555
  }));
564
556
  return _createCartAndCookie.apply(this, arguments);
565
557
  }
@@ -568,7 +560,7 @@ var CartProvider = function CartProvider(_ref) {
568
560
  }
569
561
  function _getCart() {
570
562
  _getCart = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3(cartIdAndToken) {
571
- var id, _cart2, _cartJson2, isTest;
563
+ var id, _cart2, _cartJson2, isTest, jobSource, saved;
572
564
  return _regeneratorRuntime().wrap(function _callee3$(_context3) {
573
565
  while (1) switch (_context3.prev = _context3.next) {
574
566
  case 0:
@@ -596,37 +588,53 @@ var CartProvider = function CartProvider(_ref) {
596
588
  case 14:
597
589
  return _context3.abrupt("return");
598
590
  case 15:
599
- if (!(stripeIsValidAndActive(_cartJson2) || isTest)) {
591
+ jobSource = jobSourceFieldsForApi(captureAndReadJobSource());
592
+ if (!cartNeedsSourceUpdate(_cartJson2, jobSource)) {
600
593
  _context3.next = 22;
601
594
  break;
602
595
  }
603
- _context3.next = 18;
596
+ _context3.next = 19;
597
+ return merchi.authenticatedFetch("/carts/".concat(_cart2.id, "/"), {
598
+ method: 'PATCH',
599
+ body: appendJobSourceToFormData(_cart2.toFormData(), jobSource),
600
+ query: [['embed', JSON.stringify(cartEmbed)], ['skip_rights', 'y']]
601
+ });
602
+ case 19:
603
+ saved = _context3.sent;
604
+ _cart2.fromJson(saved.cart);
605
+ _cartJson2 = _cart2.toJson();
606
+ case 22:
607
+ if (!(stripeIsValidAndActive(_cartJson2) || isTest)) {
608
+ _context3.next = 29;
609
+ break;
610
+ }
611
+ _context3.next = 25;
604
612
  return attachSavedClientIfNeeded(_cartJson2);
605
- case 18:
613
+ case 25:
606
614
  _cartJson2 = _context3.sent;
607
615
  setCart(_objectSpread({}, _cartJson2));
608
- _context3.next = 24;
616
+ _context3.next = 31;
609
617
  break;
610
- case 22:
618
+ case 29:
611
619
  console.error("MErhci cart error: Stripe payment " + "options have not been correctly set up. " + "Check the company profile payment options tab " + "to set and edit stripe payment options.");
612
620
  setCartSettingsInvalid(true);
613
- case 24:
614
- _context3.next = 30;
621
+ case 31:
622
+ _context3.next = 37;
615
623
  break;
616
- case 26:
617
- _context3.prev = 26;
624
+ case 33:
625
+ _context3.prev = 33;
618
626
  _context3.t0 = _context3["catch"](3);
619
- _context3.next = 30;
627
+ _context3.next = 37;
620
628
  return createCartAndCookie();
621
- case 30:
622
- _context3.prev = 30;
629
+ case 37:
630
+ _context3.prev = 37;
623
631
  setFetchingCart(false);
624
- return _context3.finish(30);
625
- case 33:
632
+ return _context3.finish(37);
633
+ case 40:
626
634
  case "end":
627
635
  return _context3.stop();
628
636
  }
629
- }, _callee3, null, [[3, 26, 30, 33]]);
637
+ }, _callee3, null, [[3, 33, 37, 40]]);
630
638
  }));
631
639
  return _getCart.apply(this, arguments);
632
640
  }
@@ -6,6 +6,7 @@ export * from './currency';
6
6
  export * from './helpers';
7
7
  export * from './invoice';
8
8
  export * from './job';
9
+ export * from './job_source';
9
10
  export * from './local_storage';
10
11
  export * from './product';
11
12
  export * from './shipment';
@@ -0,0 +1,139 @@
1
+ function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); }
2
+ function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
3
+ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
4
+ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
5
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
6
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
7
+ function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
8
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : String(i); }
9
+ function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
10
+ function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
11
+ function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
12
+ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
13
+ function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
14
+ export var JOB_SOURCE_STORAGE_KEY = 'merchi_job_source';
15
+ var CLICK_ID_PARAMS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid'];
16
+ var UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
17
+ function trimTo(value, limit) {
18
+ if (!value) return undefined;
19
+ var text = value.trim().slice(0, limit);
20
+ return text || undefined;
21
+ }
22
+ function readSearchParams() {
23
+ if (typeof window === 'undefined') return new URLSearchParams();
24
+ try {
25
+ return new URLSearchParams(window.location.search);
26
+ } catch (_unused) {
27
+ return new URLSearchParams();
28
+ }
29
+ }
30
+ function readStoredSource() {
31
+ if (typeof window === 'undefined') return null;
32
+ try {
33
+ var raw = window.localStorage.getItem(JOB_SOURCE_STORAGE_KEY);
34
+ if (!raw) return null;
35
+ var parsed = JSON.parse(raw);
36
+ if (!parsed || _typeof(parsed) !== 'object') return null;
37
+ return parsed;
38
+ } catch (_unused2) {
39
+ return null;
40
+ }
41
+ }
42
+ function writeStoredSource(source) {
43
+ if (typeof window === 'undefined') return;
44
+ try {
45
+ window.localStorage.setItem(JOB_SOURCE_STORAGE_KEY, JSON.stringify(source));
46
+ } catch (_unused3) {
47
+ // Ignore quota / private-mode failures.
48
+ }
49
+ }
50
+ function currentLanding() {
51
+ if (typeof window === 'undefined') return undefined;
52
+ try {
53
+ var path = "".concat(window.location.pathname || '/').concat(window.location.search || '');
54
+ return trimTo(path, 512);
55
+ } catch (_unused4) {
56
+ return undefined;
57
+ }
58
+ }
59
+ function currentReferrer() {
60
+ if (typeof document === 'undefined') return undefined;
61
+ try {
62
+ return trimTo(document.referrer, 512);
63
+ } catch (_unused5) {
64
+ return undefined;
65
+ }
66
+ }
67
+ export function sourceFromLocation() {
68
+ var params = readSearchParams();
69
+ var source = {
70
+ utmSource: trimTo(params.get('utm_source'), 256),
71
+ utmMedium: trimTo(params.get('utm_medium'), 256),
72
+ utmCampaign: trimTo(params.get('utm_campaign'), 256),
73
+ utmContent: trimTo(params.get('utm_content'), 256),
74
+ utmTerm: trimTo(params.get('utm_term'), 256),
75
+ sourceLanding: currentLanding(),
76
+ sourceReferrer: currentReferrer()
77
+ };
78
+ var _iterator = _createForOfIteratorHelper(CLICK_ID_PARAMS),
79
+ _step;
80
+ try {
81
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
82
+ var key = _step.value;
83
+ var value = trimTo(params.get(key), 256);
84
+ if (value) {
85
+ source.sourceClickId = value;
86
+ break;
87
+ }
88
+ }
89
+ } catch (err) {
90
+ _iterator.e(err);
91
+ } finally {
92
+ _iterator.f();
93
+ }
94
+ return source;
95
+ }
96
+ export function hasPaidClick(source) {
97
+ return Boolean(source === null || source === void 0 ? void 0 : source.sourceClickId);
98
+ }
99
+ export function hasSourceData(source) {
100
+ if (!source) return false;
101
+ return Boolean(source.utmSource || source.utmMedium || source.utmCampaign || source.utmContent || source.utmTerm || source.sourceClickId || source.sourceLanding || source.sourceReferrer);
102
+ }
103
+ export function captureAndReadJobSource() {
104
+ var incoming = sourceFromLocation();
105
+ var stored = readStoredSource();
106
+ if (hasPaidClick(incoming) || !hasSourceData(stored)) {
107
+ var next = _objectSpread(_objectSpread({}, stored || {}), Object.fromEntries(Object.entries(incoming).filter(function (_ref) {
108
+ var _ref2 = _slicedToArray(_ref, 2),
109
+ value = _ref2[1];
110
+ return Boolean(value);
111
+ })));
112
+ if (hasSourceData(next)) writeStoredSource(next);
113
+ return next;
114
+ }
115
+ return stored || incoming;
116
+ }
117
+ export function jobSourceFieldsForApi(source) {
118
+ var resolved = source || captureAndReadJobSource();
119
+ var out = {};
120
+ Object.keys(resolved).forEach(function (key) {
121
+ var value = resolved[key];
122
+ if (value) out[key] = value;
123
+ });
124
+ return out;
125
+ }
126
+ export function cartNeedsSourceUpdate(cart, source) {
127
+ if (!hasSourceData(source)) return false;
128
+ if (!(cart !== null && cart !== void 0 && cart.utmSource) && !(cart !== null && cart !== void 0 && cart.sourceClickId) && !(cart !== null && cart !== void 0 && cart.sourceChannel)) return true;
129
+ return Boolean(source.sourceClickId && !(cart !== null && cart !== void 0 && cart.sourceClickId));
130
+ }
131
+ export var JOB_SOURCE_URL_KEYS = [].concat(UTM_PARAMS, CLICK_ID_PARAMS);
132
+ export function appendJobSourceToFormData(data, source) {
133
+ var fields = jobSourceFieldsForApi(source);
134
+ Object.keys(fields).forEach(function (key) {
135
+ var value = fields[key];
136
+ if (value) data.append(key, value);
137
+ });
138
+ return data;
139
+ }
@@ -0,0 +1,75 @@
1
+ import { JOB_SOURCE_STORAGE_KEY, captureAndReadJobSource, cartNeedsSourceUpdate, hasPaidClick } from './job_source';
2
+ describe('job source capture', function () {
3
+ var originalLocation = window.location;
4
+ var originalReferrer = document.referrer;
5
+ beforeEach(function () {
6
+ window.localStorage.clear();
7
+ delete window.location;
8
+ window.location = {
9
+ search: '',
10
+ pathname: '/tyvek-wristbands'
11
+ };
12
+ Object.defineProperty(document, 'referrer', {
13
+ configurable: true,
14
+ value: ''
15
+ });
16
+ });
17
+ afterEach(function () {
18
+ window.location = originalLocation;
19
+ Object.defineProperty(document, 'referrer', {
20
+ configurable: true,
21
+ value: originalReferrer
22
+ });
23
+ window.localStorage.clear();
24
+ });
25
+ it('captures google ads click ids and landing path', function () {
26
+ window.location = {
27
+ search: '?utm_source=google&utm_medium=cpc&gclid=EAIa&utm_campaign=bands',
28
+ pathname: '/tyvek-wristbands'
29
+ };
30
+ var source = captureAndReadJobSource();
31
+ expect(source.utmSource).toBe('google');
32
+ expect(source.utmMedium).toBe('cpc');
33
+ expect(source.utmCampaign).toBe('bands');
34
+ expect(source.sourceClickId).toBe('EAIa');
35
+ expect(source.sourceLanding).toBe('/tyvek-wristbands?utm_source=google&utm_medium=cpc&gclid=EAIa&utm_campaign=bands');
36
+ expect(hasPaidClick(source)).toBe(true);
37
+ });
38
+ it('keeps first-touch UTMs unless a paid click arrives later', function () {
39
+ window.location = {
40
+ search: '?utm_source=newsletter&utm_medium=email',
41
+ pathname: '/'
42
+ };
43
+ captureAndReadJobSource();
44
+ window.location = {
45
+ search: '?utm_source=google&utm_medium=cpc',
46
+ pathname: '/product'
47
+ };
48
+ expect(captureAndReadJobSource().utmSource).toBe('newsletter');
49
+ window.location = {
50
+ search: '?gclid=NEWCLICK&utm_source=google',
51
+ pathname: '/product'
52
+ };
53
+ expect(captureAndReadJobSource().sourceClickId).toBe('NEWCLICK');
54
+ expect(window.localStorage.getItem(JOB_SOURCE_STORAGE_KEY)).toContain('NEWCLICK');
55
+ });
56
+ it('knows when an existing cart still needs source fields', function () {
57
+ expect(cartNeedsSourceUpdate({}, {
58
+ utmSource: 'google',
59
+ sourceClickId: 'x'
60
+ })).toBe(true);
61
+ expect(cartNeedsSourceUpdate({
62
+ utmSource: 'google'
63
+ }, {
64
+ utmSource: 'google',
65
+ sourceClickId: 'x'
66
+ })).toBe(true);
67
+ expect(cartNeedsSourceUpdate({
68
+ utmSource: 'google',
69
+ sourceClickId: 'x'
70
+ }, {
71
+ utmSource: 'google',
72
+ sourceClickId: 'x'
73
+ })).toBe(false);
74
+ });
75
+ });
@@ -19,6 +19,7 @@ var _product = require("./utilities/product");
19
19
  var _local_storage = require("./utilities/local_storage");
20
20
  var _user = require("./utilities/user");
21
21
  var _test_checkout = require("./utilities/test_checkout");
22
+ var _job_source = require("./utilities/job_source");
22
23
  var _jsxRuntime = require("react/jsx-runtime");
23
24
  function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(e) { return e ? t : r; })(e); }
24
25
  function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != _typeof(e) && "function" != typeof e) return { "default": e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n["default"] = e, t && t.set(e, n), n; }
@@ -507,66 +508,57 @@ var CartProvider = function CartProvider(_ref) {
507
508
  }
508
509
  function _createCartAndCookie() {
509
510
  _createCartAndCookie = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(callback) {
510
- var isTest, _cart, created, _cartJson;
511
+ var isTest, jobSource, _cart, query, created, _cartJson;
511
512
  return _regeneratorRuntime().wrap(function _callee2$(_context2) {
512
513
  while (1) switch (_context2.prev = _context2.next) {
513
514
  case 0:
514
515
  setFetchingCart(true);
515
516
  _context2.prev = 1;
516
517
  isTest = (0, _test_checkout.readTestCheckoutFlag)();
518
+ jobSource = (0, _job_source.jobSourceFieldsForApi)((0, _job_source.captureAndReadJobSource)());
517
519
  _cart = (0, _cart3.makeCart)({
518
520
  domain: {
519
521
  id: domainId
520
522
  },
521
523
  isTest: isTest
522
524
  }, true);
523
- if (!isTest) {
524
- _context2.next = 11;
525
- break;
526
- }
527
- _context2.next = 7;
525
+ query = [['embed', JSON.stringify(_helpers.cartEmbed)], ['skip_rights', 'y']];
526
+ if (isTest) query.push(['is_test', 'true']);
527
+ _context2.next = 9;
528
528
  return merchi.authenticatedFetch('/carts/', {
529
529
  method: 'POST',
530
- body: _cart.toFormData(),
531
- query: [['embed', JSON.stringify(_helpers.cartEmbed)], ['skip_rights', 'y'], ['is_test', 'true']]
530
+ body: (0, _job_source.appendJobSourceToFormData)(_cart.toFormData(), jobSource),
531
+ query: query
532
532
  });
533
- case 7:
533
+ case 9:
534
534
  created = _context2.sent;
535
535
  _cart.fromJson(created.cart);
536
536
  _context2.next = 13;
537
- break;
538
- case 11:
539
- _context2.next = 13;
540
- return _cart.create({
541
- embed: _helpers.cartEmbed
542
- });
543
- case 13:
544
- _context2.next = 15;
545
537
  return _cart.toJson();
546
- case 15:
538
+ case 13:
547
539
  _cartJson = _context2.sent;
548
540
  if (domainId) (0, _cookie.setCartCookie)(Number(domainId), _cartJson, undefined, isTest);
549
- _context2.next = 19;
541
+ _context2.next = 17;
550
542
  return attachSavedClientIfNeeded(_cartJson);
551
- case 19:
543
+ case 17:
552
544
  _cartJson = _context2.sent;
553
545
  setCart(_objectSpread({}, _cartJson));
554
546
  if (callback) callback();
555
- _context2.next = 27;
547
+ _context2.next = 25;
556
548
  break;
557
- case 24:
558
- _context2.prev = 24;
549
+ case 22:
550
+ _context2.prev = 22;
559
551
  _context2.t0 = _context2["catch"](1);
560
552
  alertError(_context2.t0.errorMessage || _context2.t0.message || 'Unable to fetch cart.');
561
- case 27:
562
- _context2.prev = 27;
553
+ case 25:
554
+ _context2.prev = 25;
563
555
  setFetchingCart(false);
564
- return _context2.finish(27);
565
- case 30:
556
+ return _context2.finish(25);
557
+ case 28:
566
558
  case "end":
567
559
  return _context2.stop();
568
560
  }
569
- }, _callee2, null, [[1, 24, 27, 30]]);
561
+ }, _callee2, null, [[1, 22, 25, 28]]);
570
562
  }));
571
563
  return _createCartAndCookie.apply(this, arguments);
572
564
  }
@@ -575,7 +567,7 @@ var CartProvider = function CartProvider(_ref) {
575
567
  }
576
568
  function _getCart() {
577
569
  _getCart = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee3(cartIdAndToken) {
578
- var id, _cart2, _cartJson2, isTest;
570
+ var id, _cart2, _cartJson2, isTest, jobSource, saved;
579
571
  return _regeneratorRuntime().wrap(function _callee3$(_context3) {
580
572
  while (1) switch (_context3.prev = _context3.next) {
581
573
  case 0:
@@ -603,37 +595,53 @@ var CartProvider = function CartProvider(_ref) {
603
595
  case 14:
604
596
  return _context3.abrupt("return");
605
597
  case 15:
606
- if (!((0, _cart3.stripeIsValidAndActive)(_cartJson2) || isTest)) {
598
+ jobSource = (0, _job_source.jobSourceFieldsForApi)((0, _job_source.captureAndReadJobSource)());
599
+ if (!(0, _job_source.cartNeedsSourceUpdate)(_cartJson2, jobSource)) {
607
600
  _context3.next = 22;
608
601
  break;
609
602
  }
610
- _context3.next = 18;
603
+ _context3.next = 19;
604
+ return merchi.authenticatedFetch("/carts/".concat(_cart2.id, "/"), {
605
+ method: 'PATCH',
606
+ body: (0, _job_source.appendJobSourceToFormData)(_cart2.toFormData(), jobSource),
607
+ query: [['embed', JSON.stringify(_helpers.cartEmbed)], ['skip_rights', 'y']]
608
+ });
609
+ case 19:
610
+ saved = _context3.sent;
611
+ _cart2.fromJson(saved.cart);
612
+ _cartJson2 = _cart2.toJson();
613
+ case 22:
614
+ if (!((0, _cart3.stripeIsValidAndActive)(_cartJson2) || isTest)) {
615
+ _context3.next = 29;
616
+ break;
617
+ }
618
+ _context3.next = 25;
611
619
  return attachSavedClientIfNeeded(_cartJson2);
612
- case 18:
620
+ case 25:
613
621
  _cartJson2 = _context3.sent;
614
622
  setCart(_objectSpread({}, _cartJson2));
615
- _context3.next = 24;
623
+ _context3.next = 31;
616
624
  break;
617
- case 22:
625
+ case 29:
618
626
  console.error("MErhci cart error: Stripe payment " + "options have not been correctly set up. " + "Check the company profile payment options tab " + "to set and edit stripe payment options.");
619
627
  setCartSettingsInvalid(true);
620
- case 24:
621
- _context3.next = 30;
628
+ case 31:
629
+ _context3.next = 37;
622
630
  break;
623
- case 26:
624
- _context3.prev = 26;
631
+ case 33:
632
+ _context3.prev = 33;
625
633
  _context3.t0 = _context3["catch"](3);
626
- _context3.next = 30;
634
+ _context3.next = 37;
627
635
  return createCartAndCookie();
628
- case 30:
629
- _context3.prev = 30;
636
+ case 37:
637
+ _context3.prev = 37;
630
638
  setFetchingCart(false);
631
- return _context3.finish(30);
632
- case 33:
639
+ return _context3.finish(37);
640
+ case 40:
633
641
  case "end":
634
642
  return _context3.stop();
635
643
  }
636
- }, _callee3, null, [[3, 26, 30, 33]]);
644
+ }, _callee3, null, [[3, 33, 37, 40]]);
637
645
  }));
638
646
  return _getCart.apply(this, arguments);
639
647
  }
@@ -6,6 +6,7 @@ export * from './currency';
6
6
  export * from './helpers';
7
7
  export * from './invoice';
8
8
  export * from './job';
9
+ export * from './job_source';
9
10
  export * from './local_storage';
10
11
  export * from './product';
11
12
  export * from './shipment';
@@ -91,6 +91,17 @@ Object.keys(_job).forEach(function (key) {
91
91
  }
92
92
  });
93
93
  });
94
+ var _job_source = require("./job_source");
95
+ Object.keys(_job_source).forEach(function (key) {
96
+ if (key === "default" || key === "__esModule") return;
97
+ if (key in exports && exports[key] === _job_source[key]) return;
98
+ Object.defineProperty(exports, key, {
99
+ enumerable: true,
100
+ get: function get() {
101
+ return _job_source[key];
102
+ }
103
+ });
104
+ });
94
105
  var _local_storage = require("./local_storage");
95
106
  Object.keys(_local_storage).forEach(function (key) {
96
107
  if (key === "default" || key === "__esModule") return;
@@ -0,0 +1,23 @@
1
+ export declare const JOB_SOURCE_STORAGE_KEY = "merchi_job_source";
2
+ export interface JobSourceFields {
3
+ utmSource?: string;
4
+ utmMedium?: string;
5
+ utmCampaign?: string;
6
+ utmContent?: string;
7
+ utmTerm?: string;
8
+ sourceClickId?: string;
9
+ sourceLanding?: string;
10
+ sourceReferrer?: string;
11
+ }
12
+ export declare function sourceFromLocation(): JobSourceFields;
13
+ export declare function hasPaidClick(source: JobSourceFields | null | undefined): boolean;
14
+ export declare function hasSourceData(source: JobSourceFields | null | undefined): boolean;
15
+ export declare function captureAndReadJobSource(): JobSourceFields;
16
+ export declare function jobSourceFieldsForApi(source?: JobSourceFields | null): JobSourceFields;
17
+ export declare function cartNeedsSourceUpdate(cart: {
18
+ utmSource?: string;
19
+ sourceClickId?: string;
20
+ sourceChannel?: string;
21
+ } | null | undefined, source: JobSourceFields): boolean;
22
+ export declare const JOB_SOURCE_URL_KEYS: ("gclid" | "gbraid" | "wbraid" | "fbclid" | "msclkid" | "utm_source" | "utm_medium" | "utm_campaign" | "utm_content" | "utm_term")[];
23
+ export declare function appendJobSourceToFormData(data: FormData, source?: JobSourceFields | null): FormData;
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.JOB_SOURCE_URL_KEYS = exports.JOB_SOURCE_STORAGE_KEY = void 0;
7
+ exports.appendJobSourceToFormData = appendJobSourceToFormData;
8
+ exports.captureAndReadJobSource = captureAndReadJobSource;
9
+ exports.cartNeedsSourceUpdate = cartNeedsSourceUpdate;
10
+ exports.hasPaidClick = hasPaidClick;
11
+ exports.hasSourceData = hasSourceData;
12
+ exports.jobSourceFieldsForApi = jobSourceFieldsForApi;
13
+ exports.sourceFromLocation = sourceFromLocation;
14
+ function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); }
15
+ function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
16
+ function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } }
17
+ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
18
+ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
19
+ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
20
+ function _defineProperty(obj, key, value) { key = _toPropertyKey(key); if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
21
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : String(i); }
22
+ function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
23
+ function _createForOfIteratorHelper(o, allowArrayLike) { var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; if (!it) { if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { if (it) o = it; var i = 0; var F = function F() {}; return { s: F, n: function n() { if (i >= o.length) return { done: true }; return { done: false, value: o[i++] }; }, e: function e(_e) { throw _e; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var normalCompletion = true, didErr = false, err; return { s: function s() { it = it.call(o); }, n: function n() { var step = it.next(); normalCompletion = step.done; return step; }, e: function e(_e2) { didErr = true; err = _e2; }, f: function f() { try { if (!normalCompletion && it["return"] != null) it["return"](); } finally { if (didErr) throw err; } } }; }
24
+ function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); }
25
+ function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; return arr2; }
26
+ function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
27
+ var JOB_SOURCE_STORAGE_KEY = exports.JOB_SOURCE_STORAGE_KEY = 'merchi_job_source';
28
+ var CLICK_ID_PARAMS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid'];
29
+ var UTM_PARAMS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
30
+ function trimTo(value, limit) {
31
+ if (!value) return undefined;
32
+ var text = value.trim().slice(0, limit);
33
+ return text || undefined;
34
+ }
35
+ function readSearchParams() {
36
+ if (typeof window === 'undefined') return new URLSearchParams();
37
+ try {
38
+ return new URLSearchParams(window.location.search);
39
+ } catch (_unused) {
40
+ return new URLSearchParams();
41
+ }
42
+ }
43
+ function readStoredSource() {
44
+ if (typeof window === 'undefined') return null;
45
+ try {
46
+ var raw = window.localStorage.getItem(JOB_SOURCE_STORAGE_KEY);
47
+ if (!raw) return null;
48
+ var parsed = JSON.parse(raw);
49
+ if (!parsed || _typeof(parsed) !== 'object') return null;
50
+ return parsed;
51
+ } catch (_unused2) {
52
+ return null;
53
+ }
54
+ }
55
+ function writeStoredSource(source) {
56
+ if (typeof window === 'undefined') return;
57
+ try {
58
+ window.localStorage.setItem(JOB_SOURCE_STORAGE_KEY, JSON.stringify(source));
59
+ } catch (_unused3) {
60
+ // Ignore quota / private-mode failures.
61
+ }
62
+ }
63
+ function currentLanding() {
64
+ if (typeof window === 'undefined') return undefined;
65
+ try {
66
+ var path = "".concat(window.location.pathname || '/').concat(window.location.search || '');
67
+ return trimTo(path, 512);
68
+ } catch (_unused4) {
69
+ return undefined;
70
+ }
71
+ }
72
+ function currentReferrer() {
73
+ if (typeof document === 'undefined') return undefined;
74
+ try {
75
+ return trimTo(document.referrer, 512);
76
+ } catch (_unused5) {
77
+ return undefined;
78
+ }
79
+ }
80
+ function sourceFromLocation() {
81
+ var params = readSearchParams();
82
+ var source = {
83
+ utmSource: trimTo(params.get('utm_source'), 256),
84
+ utmMedium: trimTo(params.get('utm_medium'), 256),
85
+ utmCampaign: trimTo(params.get('utm_campaign'), 256),
86
+ utmContent: trimTo(params.get('utm_content'), 256),
87
+ utmTerm: trimTo(params.get('utm_term'), 256),
88
+ sourceLanding: currentLanding(),
89
+ sourceReferrer: currentReferrer()
90
+ };
91
+ var _iterator = _createForOfIteratorHelper(CLICK_ID_PARAMS),
92
+ _step;
93
+ try {
94
+ for (_iterator.s(); !(_step = _iterator.n()).done;) {
95
+ var key = _step.value;
96
+ var value = trimTo(params.get(key), 256);
97
+ if (value) {
98
+ source.sourceClickId = value;
99
+ break;
100
+ }
101
+ }
102
+ } catch (err) {
103
+ _iterator.e(err);
104
+ } finally {
105
+ _iterator.f();
106
+ }
107
+ return source;
108
+ }
109
+ function hasPaidClick(source) {
110
+ return Boolean(source === null || source === void 0 ? void 0 : source.sourceClickId);
111
+ }
112
+ function hasSourceData(source) {
113
+ if (!source) return false;
114
+ return Boolean(source.utmSource || source.utmMedium || source.utmCampaign || source.utmContent || source.utmTerm || source.sourceClickId || source.sourceLanding || source.sourceReferrer);
115
+ }
116
+ function captureAndReadJobSource() {
117
+ var incoming = sourceFromLocation();
118
+ var stored = readStoredSource();
119
+ if (hasPaidClick(incoming) || !hasSourceData(stored)) {
120
+ var next = _objectSpread(_objectSpread({}, stored || {}), Object.fromEntries(Object.entries(incoming).filter(function (_ref) {
121
+ var _ref2 = _slicedToArray(_ref, 2),
122
+ value = _ref2[1];
123
+ return Boolean(value);
124
+ })));
125
+ if (hasSourceData(next)) writeStoredSource(next);
126
+ return next;
127
+ }
128
+ return stored || incoming;
129
+ }
130
+ function jobSourceFieldsForApi(source) {
131
+ var resolved = source || captureAndReadJobSource();
132
+ var out = {};
133
+ Object.keys(resolved).forEach(function (key) {
134
+ var value = resolved[key];
135
+ if (value) out[key] = value;
136
+ });
137
+ return out;
138
+ }
139
+ function cartNeedsSourceUpdate(cart, source) {
140
+ if (!hasSourceData(source)) return false;
141
+ if (!(cart !== null && cart !== void 0 && cart.utmSource) && !(cart !== null && cart !== void 0 && cart.sourceClickId) && !(cart !== null && cart !== void 0 && cart.sourceChannel)) return true;
142
+ return Boolean(source.sourceClickId && !(cart !== null && cart !== void 0 && cart.sourceClickId));
143
+ }
144
+ var JOB_SOURCE_URL_KEYS = exports.JOB_SOURCE_URL_KEYS = [].concat(UTM_PARAMS, CLICK_ID_PARAMS);
145
+ function appendJobSourceToFormData(data, source) {
146
+ var fields = jobSourceFieldsForApi(source);
147
+ Object.keys(fields).forEach(function (key) {
148
+ var value = fields[key];
149
+ if (value) data.append(key, value);
150
+ });
151
+ return data;
152
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merchi_cart",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
4
4
  "description": "Merchi's cart",
5
5
  "source": "src/index.ts",
6
6
  "main": "lib/index.js",
@@ -21,6 +21,12 @@ import {
21
21
  } from './utilities/local_storage';
22
22
  import { makeUser } from './utilities/user';
23
23
  import { persistTestCheckoutFlag, readTestCheckoutFlag } from './utilities/test_checkout';
24
+ import {
25
+ appendJobSourceToFormData,
26
+ captureAndReadJobSource,
27
+ cartNeedsSourceUpdate,
28
+ jobSourceFieldsForApi,
29
+ } from './utilities/job_source';
24
30
 
25
31
  export interface PropsCart {
26
32
  cart: any;
@@ -622,21 +628,19 @@ const CartProvider = ({
622
628
  setFetchingCart(true);
623
629
  try {
624
630
  const isTest = readTestCheckoutFlag();
631
+ const jobSource = jobSourceFieldsForApi(captureAndReadJobSource());
625
632
  const cart = makeCart({domain: {id: domainId}, isTest}, true);
626
- if (isTest) {
627
- const created = await merchi.authenticatedFetch('/carts/', {
628
- method: 'POST',
629
- body: cart.toFormData(),
630
- query: [
631
- ['embed', JSON.stringify(cartEmbed)],
632
- ['skip_rights', 'y'],
633
- ['is_test', 'true'],
634
- ],
635
- });
636
- cart.fromJson(created.cart);
637
- } else {
638
- await cart.create({embed: cartEmbed});
639
- }
633
+ const query: Array<[string, string]> = [
634
+ ['embed', JSON.stringify(cartEmbed)],
635
+ ['skip_rights', 'y'],
636
+ ];
637
+ if (isTest) query.push(['is_test', 'true']);
638
+ const created = await merchi.authenticatedFetch('/carts/', {
639
+ method: 'POST',
640
+ body: appendJobSourceToFormData(cart.toFormData() as FormData, jobSource),
641
+ query,
642
+ });
643
+ cart.fromJson(created.cart);
640
644
  let cartJson = await cart.toJson();
641
645
  if (domainId) setCartCookie(Number(domainId), cartJson, undefined, isTest);
642
646
  cartJson = await attachSavedClientIfNeeded(cartJson);
@@ -663,6 +667,19 @@ const CartProvider = ({
663
667
  await createCartAndCookie();
664
668
  return;
665
669
  }
670
+ const jobSource = jobSourceFieldsForApi(captureAndReadJobSource());
671
+ if (cartNeedsSourceUpdate(cartJson, jobSource)) {
672
+ const saved = await merchi.authenticatedFetch(`/carts/${cart.id}/`, {
673
+ method: 'PATCH',
674
+ body: appendJobSourceToFormData(cart.toFormData() as FormData, jobSource),
675
+ query: [
676
+ ['embed', JSON.stringify(cartEmbed)],
677
+ ['skip_rights', 'y'],
678
+ ],
679
+ });
680
+ cart.fromJson(saved.cart);
681
+ cartJson = cart.toJson();
682
+ }
666
683
  if (stripeIsValidAndActive(cartJson) || isTest) {
667
684
  cartJson = await attachSavedClientIfNeeded(cartJson);
668
685
  setCart({...cartJson});
@@ -6,6 +6,7 @@ export * from './currency';
6
6
  export * from './helpers';
7
7
  export * from './invoice';
8
8
  export * from './job';
9
+ export * from './job_source';
9
10
  export * from './local_storage';
10
11
  export * from './product';
11
12
  export * from './shipment';
@@ -0,0 +1,86 @@
1
+ import {
2
+ JOB_SOURCE_STORAGE_KEY,
3
+ captureAndReadJobSource,
4
+ cartNeedsSourceUpdate,
5
+ hasPaidClick,
6
+ } from './job_source';
7
+
8
+ describe('job source capture', () => {
9
+ const originalLocation = window.location;
10
+ const originalReferrer = document.referrer;
11
+
12
+ beforeEach(() => {
13
+ window.localStorage.clear();
14
+ delete (window as any).location;
15
+ (window as any).location = {
16
+ search: '',
17
+ pathname: '/tyvek-wristbands',
18
+ };
19
+ Object.defineProperty(document, 'referrer', {
20
+ configurable: true,
21
+ value: '',
22
+ });
23
+ });
24
+
25
+ afterEach(() => {
26
+ (window as any).location = originalLocation;
27
+ Object.defineProperty(document, 'referrer', {
28
+ configurable: true,
29
+ value: originalReferrer,
30
+ });
31
+ window.localStorage.clear();
32
+ });
33
+
34
+ it('captures google ads click ids and landing path', () => {
35
+ (window as any).location = {
36
+ search: '?utm_source=google&utm_medium=cpc&gclid=EAIa&utm_campaign=bands',
37
+ pathname: '/tyvek-wristbands',
38
+ };
39
+ const source = captureAndReadJobSource();
40
+ expect(source.utmSource).toBe('google');
41
+ expect(source.utmMedium).toBe('cpc');
42
+ expect(source.utmCampaign).toBe('bands');
43
+ expect(source.sourceClickId).toBe('EAIa');
44
+ expect(source.sourceLanding).toBe(
45
+ '/tyvek-wristbands?utm_source=google&utm_medium=cpc&gclid=EAIa&utm_campaign=bands'
46
+ );
47
+ expect(hasPaidClick(source)).toBe(true);
48
+ });
49
+
50
+ it('keeps first-touch UTMs unless a paid click arrives later', () => {
51
+ (window as any).location = {
52
+ search: '?utm_source=newsletter&utm_medium=email',
53
+ pathname: '/',
54
+ };
55
+ captureAndReadJobSource();
56
+ (window as any).location = {
57
+ search: '?utm_source=google&utm_medium=cpc',
58
+ pathname: '/product',
59
+ };
60
+ expect(captureAndReadJobSource().utmSource).toBe('newsletter');
61
+ (window as any).location = {
62
+ search: '?gclid=NEWCLICK&utm_source=google',
63
+ pathname: '/product',
64
+ };
65
+ expect(captureAndReadJobSource().sourceClickId).toBe('NEWCLICK');
66
+ expect(window.localStorage.getItem(JOB_SOURCE_STORAGE_KEY)).toContain('NEWCLICK');
67
+ });
68
+
69
+ it('knows when an existing cart still needs source fields', () => {
70
+ expect(
71
+ cartNeedsSourceUpdate({}, { utmSource: 'google', sourceClickId: 'x' })
72
+ ).toBe(true);
73
+ expect(
74
+ cartNeedsSourceUpdate(
75
+ { utmSource: 'google' },
76
+ { utmSource: 'google', sourceClickId: 'x' }
77
+ )
78
+ ).toBe(true);
79
+ expect(
80
+ cartNeedsSourceUpdate(
81
+ { utmSource: 'google', sourceClickId: 'x' },
82
+ { utmSource: 'google', sourceClickId: 'x' }
83
+ )
84
+ ).toBe(false);
85
+ });
86
+ });
@@ -0,0 +1,165 @@
1
+ export const JOB_SOURCE_STORAGE_KEY = 'merchi_job_source';
2
+
3
+ const CLICK_ID_PARAMS = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid'] as const;
4
+ const UTM_PARAMS = [
5
+ 'utm_source',
6
+ 'utm_medium',
7
+ 'utm_campaign',
8
+ 'utm_content',
9
+ 'utm_term',
10
+ ] as const;
11
+
12
+ export interface JobSourceFields {
13
+ utmSource?: string;
14
+ utmMedium?: string;
15
+ utmCampaign?: string;
16
+ utmContent?: string;
17
+ utmTerm?: string;
18
+ sourceClickId?: string;
19
+ sourceLanding?: string;
20
+ sourceReferrer?: string;
21
+ }
22
+
23
+ function trimTo(value: string | null | undefined, limit: number): string | undefined {
24
+ if (!value) return undefined;
25
+ const text = value.trim().slice(0, limit);
26
+ return text || undefined;
27
+ }
28
+
29
+ function readSearchParams(): URLSearchParams {
30
+ if (typeof window === 'undefined') return new URLSearchParams();
31
+ try {
32
+ return new URLSearchParams(window.location.search);
33
+ } catch {
34
+ return new URLSearchParams();
35
+ }
36
+ }
37
+
38
+ function readStoredSource(): JobSourceFields | null {
39
+ if (typeof window === 'undefined') return null;
40
+ try {
41
+ const raw = window.localStorage.getItem(JOB_SOURCE_STORAGE_KEY);
42
+ if (!raw) return null;
43
+ const parsed = JSON.parse(raw);
44
+ if (!parsed || typeof parsed !== 'object') return null;
45
+ return parsed as JobSourceFields;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function writeStoredSource(source: JobSourceFields) {
52
+ if (typeof window === 'undefined') return;
53
+ try {
54
+ window.localStorage.setItem(JOB_SOURCE_STORAGE_KEY, JSON.stringify(source));
55
+ } catch {
56
+ // Ignore quota / private-mode failures.
57
+ }
58
+ }
59
+
60
+ function currentLanding(): string | undefined {
61
+ if (typeof window === 'undefined') return undefined;
62
+ try {
63
+ const path = `${window.location.pathname || '/'}${window.location.search || ''}`;
64
+ return trimTo(path, 512);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ function currentReferrer(): string | undefined {
71
+ if (typeof document === 'undefined') return undefined;
72
+ try {
73
+ return trimTo(document.referrer, 512);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ export function sourceFromLocation(): JobSourceFields {
80
+ const params = readSearchParams();
81
+ const source: JobSourceFields = {
82
+ utmSource: trimTo(params.get('utm_source'), 256),
83
+ utmMedium: trimTo(params.get('utm_medium'), 256),
84
+ utmCampaign: trimTo(params.get('utm_campaign'), 256),
85
+ utmContent: trimTo(params.get('utm_content'), 256),
86
+ utmTerm: trimTo(params.get('utm_term'), 256),
87
+ sourceLanding: currentLanding(),
88
+ sourceReferrer: currentReferrer(),
89
+ };
90
+ for (const key of CLICK_ID_PARAMS) {
91
+ const value = trimTo(params.get(key), 256);
92
+ if (value) {
93
+ source.sourceClickId = value;
94
+ break;
95
+ }
96
+ }
97
+ return source;
98
+ }
99
+
100
+ export function hasPaidClick(source: JobSourceFields | null | undefined): boolean {
101
+ return Boolean(source?.sourceClickId);
102
+ }
103
+
104
+ export function hasSourceData(source: JobSourceFields | null | undefined): boolean {
105
+ if (!source) return false;
106
+ return Boolean(
107
+ source.utmSource ||
108
+ source.utmMedium ||
109
+ source.utmCampaign ||
110
+ source.utmContent ||
111
+ source.utmTerm ||
112
+ source.sourceClickId ||
113
+ source.sourceLanding ||
114
+ source.sourceReferrer
115
+ );
116
+ }
117
+
118
+ export function captureAndReadJobSource(): JobSourceFields {
119
+ const incoming = sourceFromLocation();
120
+ const stored = readStoredSource();
121
+ if (hasPaidClick(incoming) || !hasSourceData(stored)) {
122
+ const next = {
123
+ ...(stored || {}),
124
+ ...Object.fromEntries(
125
+ Object.entries(incoming).filter(([, value]) => Boolean(value))
126
+ ),
127
+ } as JobSourceFields;
128
+ if (hasSourceData(next)) writeStoredSource(next);
129
+ return next;
130
+ }
131
+ return stored || incoming;
132
+ }
133
+
134
+ export function jobSourceFieldsForApi(source?: JobSourceFields | null): JobSourceFields {
135
+ const resolved = source || captureAndReadJobSource();
136
+ const out: JobSourceFields = {};
137
+ (Object.keys(resolved) as Array<keyof JobSourceFields>).forEach((key) => {
138
+ const value = resolved[key];
139
+ if (value) out[key] = value;
140
+ });
141
+ return out;
142
+ }
143
+
144
+ export function cartNeedsSourceUpdate(
145
+ cart: { utmSource?: string; sourceClickId?: string; sourceChannel?: string } | null | undefined,
146
+ source: JobSourceFields
147
+ ): boolean {
148
+ if (!hasSourceData(source)) return false;
149
+ if (!cart?.utmSource && !cart?.sourceClickId && !cart?.sourceChannel) return true;
150
+ return Boolean(source.sourceClickId && !cart?.sourceClickId);
151
+ }
152
+
153
+ export const JOB_SOURCE_URL_KEYS = [...UTM_PARAMS, ...CLICK_ID_PARAMS];
154
+
155
+ export function appendJobSourceToFormData(
156
+ data: FormData,
157
+ source?: JobSourceFields | null
158
+ ): FormData {
159
+ const fields = jobSourceFieldsForApi(source);
160
+ (Object.keys(fields) as Array<keyof JobSourceFields>).forEach((key) => {
161
+ const value = fields[key];
162
+ if (value) data.append(key, value);
163
+ });
164
+ return data;
165
+ }