@merchi/cart 1.4.4 → 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.
Files changed (49) hide show
  1. package/esm/CartProvider.js +58 -20
  2. package/esm/forms/FormShipmentAddressAndNotes.js +24 -3
  3. package/esm/stripe/LegacyStripeCardForm.js +11 -4
  4. package/esm/stripe/StripeCardForm.js +122 -16
  5. package/esm/stripe/actions.js +155 -57
  6. package/esm/stripe/actions.test.js +120 -0
  7. package/esm/utilities/cookie.js +15 -12
  8. package/esm/utilities/index.js +2 -0
  9. package/esm/utilities/job_source.js +139 -0
  10. package/esm/utilities/job_source.test.js +75 -0
  11. package/esm/utilities/shipment.js +22 -0
  12. package/esm/utilities/shipment.test.js +60 -0
  13. package/esm/utilities/test_checkout.js +25 -0
  14. package/esm/utilities/test_checkout.test.js +31 -0
  15. package/lib/CartProvider.js +58 -20
  16. package/lib/forms/FormShipmentAddressAndNotes.js +22 -1
  17. package/lib/stripe/LegacyStripeCardForm.js +10 -3
  18. package/lib/stripe/StripeCardForm.js +123 -17
  19. package/lib/stripe/actions.d.ts +2 -0
  20. package/lib/stripe/actions.js +157 -57
  21. package/lib/stripe/actions.test.d.ts +1 -0
  22. package/lib/utilities/cookie.d.ts +3 -3
  23. package/lib/utilities/cookie.js +15 -12
  24. package/lib/utilities/index.d.ts +2 -0
  25. package/lib/utilities/index.js +22 -0
  26. package/lib/utilities/job_source.d.ts +23 -0
  27. package/lib/utilities/job_source.js +152 -0
  28. package/lib/utilities/job_source.test.d.ts +1 -0
  29. package/lib/utilities/shipment.d.ts +15 -0
  30. package/lib/utilities/shipment.js +24 -0
  31. package/lib/utilities/shipment.test.d.ts +1 -0
  32. package/lib/utilities/test_checkout.d.ts +6 -0
  33. package/lib/utilities/test_checkout.js +34 -0
  34. package/lib/utilities/test_checkout.test.d.ts +1 -0
  35. package/package.json +8 -5
  36. package/src/CartProvider.tsx +42 -4
  37. package/src/forms/FormShipmentAddressAndNotes.tsx +34 -2
  38. package/src/stripe/LegacyStripeCardForm.tsx +9 -3
  39. package/src/stripe/StripeCardForm.tsx +92 -8
  40. package/src/stripe/actions.test.ts +50 -0
  41. package/src/stripe/actions.ts +41 -0
  42. package/src/utilities/cookie.ts +9 -6
  43. package/src/utilities/index.ts +2 -0
  44. package/src/utilities/job_source.test.ts +86 -0
  45. package/src/utilities/job_source.ts +165 -0
  46. package/src/utilities/shipment.test.ts +68 -0
  47. package/src/utilities/shipment.ts +34 -0
  48. package/src/utilities/test_checkout.test.ts +39 -0
  49. package/src/utilities/test_checkout.ts +27 -0
@@ -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(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _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(r) { if (Array.isArray(r)) return r; }
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(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
21
+ function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : 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(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, 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 o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t["return"] || t["return"](); } finally { if (u) throw o; } } }; }
24
+ function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
25
+ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
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 {};
@@ -1,2 +1,17 @@
1
+ import { SavedCheckoutAddress } from './local_storage';
1
2
  export declare const shipmentFormId = "_shipment-address-form-id";
2
3
  export declare function cartItemsNeedShipment(cart: any): boolean;
4
+ export declare function shipmentGroupsHaveQuotes(groups?: any[] | null): boolean;
5
+ export type CheckoutShipmentHydrationAction = 'apply-saved-address' | 'refresh-quotes' | null;
6
+ /**
7
+ * When checkout hydrates a saved address (localStorage) the form shows it,
8
+ * but quotes are only fetched after the user edits the field. Returning
9
+ * `apply-saved-address` means the cart still needs that address applied so
10
+ * quotes can be generated; `refresh-quotes` means the cart already has an
11
+ * address but no usable shipment options.
12
+ */
13
+ export declare function checkoutShipmentHydrationAction(options: {
14
+ cartAddress?: SavedCheckoutAddress | null;
15
+ savedAddress?: SavedCheckoutAddress | null;
16
+ shipmentGroups?: any[] | null;
17
+ }): CheckoutShipmentHydrationAction;
@@ -4,7 +4,10 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.cartItemsNeedShipment = cartItemsNeedShipment;
7
+ exports.checkoutShipmentHydrationAction = checkoutShipmentHydrationAction;
7
8
  exports.shipmentFormId = void 0;
9
+ exports.shipmentGroupsHaveQuotes = shipmentGroupsHaveQuotes;
10
+ var _local_storage = require("./local_storage");
8
11
  var shipmentFormId = exports.shipmentFormId = '_shipment-address-form-id';
9
12
  function cartItemsNeedShipment(cart) {
10
13
  var _cart$cartItems = cart.cartItems,
@@ -15,4 +18,25 @@ function cartItemsNeedShipment(cart) {
15
18
  }
16
19
  }
17
20
  return false;
21
+ }
22
+ function shipmentGroupsHaveQuotes(groups) {
23
+ if (!(groups !== null && groups !== void 0 && groups.length)) return false;
24
+ return groups.some(function (group) {
25
+ return Array.isArray(group === null || group === void 0 ? void 0 : group.quotes) && group.quotes.length > 0;
26
+ });
27
+ }
28
+ /**
29
+ * When checkout hydrates a saved address (localStorage) the form shows it,
30
+ * but quotes are only fetched after the user edits the field. Returning
31
+ * `apply-saved-address` means the cart still needs that address applied so
32
+ * quotes can be generated; `refresh-quotes` means the cart already has an
33
+ * address but no usable shipment options.
34
+ */
35
+ function checkoutShipmentHydrationAction(options) {
36
+ var hasCartAddress = (0, _local_storage.addressHasContent)(options.cartAddress);
37
+ var hasSavedAddress = (0, _local_storage.addressHasContent)(options.savedAddress);
38
+ var hasQuotes = shipmentGroupsHaveQuotes(options.shipmentGroups);
39
+ if (!hasCartAddress && hasSavedAddress) return 'apply-saved-address';
40
+ if (hasCartAddress && !hasQuotes) return 'refresh-quotes';
41
+ return null;
18
42
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ export declare const TEST_CHECKOUT_QUERY = "merchi_test_checkout";
2
+ export declare const TEST_CHECKOUT_STORAGE_KEY = "merchi_test_checkout";
3
+ export declare const TEST_CARD_NUMBER = "4242 4242 4242 4242";
4
+ export declare function readTestCheckoutFlag(): boolean;
5
+ export declare function persistTestCheckoutFlag(): boolean;
6
+ export declare function cartCookieName(domainId: number | string, isTest?: boolean): string;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.TEST_CHECKOUT_STORAGE_KEY = exports.TEST_CHECKOUT_QUERY = exports.TEST_CARD_NUMBER = void 0;
7
+ exports.cartCookieName = cartCookieName;
8
+ exports.persistTestCheckoutFlag = persistTestCheckoutFlag;
9
+ exports.readTestCheckoutFlag = readTestCheckoutFlag;
10
+ var TEST_CHECKOUT_QUERY = exports.TEST_CHECKOUT_QUERY = 'merchi_test_checkout';
11
+ var TEST_CHECKOUT_STORAGE_KEY = exports.TEST_CHECKOUT_STORAGE_KEY = 'merchi_test_checkout';
12
+ var TEST_CARD_NUMBER = exports.TEST_CARD_NUMBER = '4242 4242 4242 4242';
13
+ function readTestCheckoutFlag() {
14
+ if (typeof window === 'undefined') {
15
+ return false;
16
+ }
17
+ try {
18
+ var params = new URLSearchParams(window.location.search);
19
+ if (params.get(TEST_CHECKOUT_QUERY) === '1') {
20
+ window.localStorage.setItem(TEST_CHECKOUT_STORAGE_KEY, '1');
21
+ return true;
22
+ }
23
+ return window.localStorage.getItem(TEST_CHECKOUT_STORAGE_KEY) === '1';
24
+ } catch (_unused) {
25
+ return false;
26
+ }
27
+ }
28
+ function persistTestCheckoutFlag() {
29
+ return readTestCheckoutFlag();
30
+ }
31
+ function cartCookieName(domainId) {
32
+ var isTest = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
33
+ return isTest ? "cart-".concat(domainId, "-test") : "cart-".concat(domainId);
34
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merchi/cart",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Merchi's cart",
5
5
  "source": "src/index.ts",
6
6
  "main": "lib/index.js",
@@ -18,7 +18,7 @@
18
18
  "copy-assets": "copyfiles -u 1 src/assets/**/* esm && copyfiles -u 1 src/assets/**/* lib",
19
19
  "compile-sass": "sass src/styles/globals.scss esm/styles/globals.css && sass src/styles/globals.scss lib/styles/globals.css",
20
20
  "start": "react-scripts start",
21
- "test": "react-scripts test",
21
+ "test": "jest",
22
22
  "eject": "react-scripts eject"
23
23
  },
24
24
  "repository": {
@@ -81,9 +81,12 @@
81
81
  "copyfiles": "^2.4.1",
82
82
  "eslint": "8.47.0",
83
83
  "eslint-config-next": "13.4.16",
84
+ "jest": "28",
85
+ "jest-environment-jsdom": "28",
84
86
  "react": "^18.0.0",
85
87
  "react-dom": "^18.0.0",
86
88
  "sass": "^1.70.0",
89
+ "ts-jest": "28",
87
90
  "typescript": "^5.1.6"
88
91
  },
89
92
  "dependencies": {
@@ -95,9 +98,9 @@
95
98
  "browser-or-node": "^3.0.0-pre.0",
96
99
  "js-cookie": "^3.0.5",
97
100
  "lodash": "^4.17.21",
98
- "@merchi/product-form": "^1.19.12",
99
- "@merchi/product-form-sdk": "^1.0.9",
100
- "@merchi/sdk": "^1.40.2",
101
+ "@merchi/product-form": "^1.19.14",
102
+ "@merchi/product-form-sdk": "^1.0.11",
103
+ "@merchi/sdk": "^1.42.1",
101
104
  "react-country-region-selector": "^3.6.1",
102
105
  "react-hook-form": "7.45.4",
103
106
  "react-icons": "^4.10.1",
@@ -20,6 +20,13 @@ import {
20
20
  saveCheckoutAddress,
21
21
  } from './utilities/local_storage';
22
22
  import { makeUser } from './utilities/user';
23
+ import { persistTestCheckoutFlag, readTestCheckoutFlag } from './utilities/test_checkout';
24
+ import {
25
+ appendJobSourceToFormData,
26
+ captureAndReadJobSource,
27
+ cartNeedsSourceUpdate,
28
+ jobSourceFieldsForApi,
29
+ } from './utilities/job_source';
23
30
 
24
31
  export interface PropsCart {
25
32
  cart: any;
@@ -620,10 +627,22 @@ const CartProvider = ({
620
627
  async function createCartAndCookie(callback?: () => void) {
621
628
  setFetchingCart(true);
622
629
  try {
623
- const cart = makeCart({domain: {id: domainId}}, true);
624
- await cart.create({embed: cartEmbed});
630
+ const isTest = readTestCheckoutFlag();
631
+ const jobSource = jobSourceFieldsForApi(captureAndReadJobSource());
632
+ const cart = makeCart({domain: {id: domainId}, isTest}, true);
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);
625
644
  let cartJson = await cart.toJson();
626
- if (domainId) setCartCookie(Number(domainId), cartJson, undefined);
645
+ if (domainId) setCartCookie(Number(domainId), cartJson, undefined, isTest);
627
646
  cartJson = await attachSavedClientIfNeeded(cartJson);
628
647
  setCart({...cartJson});
629
648
  if (callback) callback();
@@ -643,7 +662,25 @@ const CartProvider = ({
643
662
  try {
644
663
  const cart = await merchi.Cart.get(id, {embed: cartEmbed});
645
664
  let cartJson = await cart.toJson();
646
- if (stripeIsValidAndActive(cartJson)) {
665
+ const isTest = readTestCheckoutFlag();
666
+ if (Boolean(cartJson.isTest) !== isTest) {
667
+ await createCartAndCookie();
668
+ return;
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
+ }
683
+ if (stripeIsValidAndActive(cartJson) || isTest) {
647
684
  cartJson = await attachSavedClientIfNeeded(cartJson);
648
685
  setCart({...cartJson});
649
686
  } else {
@@ -833,6 +870,7 @@ const CartProvider = ({
833
870
  }
834
871
 
835
872
  useEffect(() => {
873
+ persistTestCheckoutFlag();
836
874
  if (domainId) actionGetMerchiCart();
837
875
  if (domainId && includeTheme) actionFetchTheme();
838
876
  }, [domainId]);
@@ -2,10 +2,13 @@ import { useForm } from "react-hook-form";
2
2
  import InputsAddress from "./InputsAddress";
3
3
  import { CheckoutContainer, InnerContainer } from "../components/containers";
4
4
  import { Title } from "../components";
5
- import { shipmentFormId } from "../utilities/shipment";
5
+ import {
6
+ checkoutShipmentHydrationAction,
7
+ shipmentFormId,
8
+ } from "../utilities/shipment";
6
9
  import { faMapMarkerAlt } from "@fortawesome/free-solid-svg-icons";
7
10
  import { useCartContext } from "../CartProvider";
8
- import { useState } from "react";
11
+ import { useEffect, useRef, useState } from "react";
9
12
  import { getCartCookieToken } from "../utilities/cookie";
10
13
  import { makeAddress, sanitizeAddressFields } from "../utilities/address";
11
14
  import { makeCart } from "../utilities/cart";
@@ -114,6 +117,8 @@ export function ActiveFormShipmentAddressAndNotes() {
114
117
  setCart,
115
118
  setLoadingTotals,
116
119
  setActiveTabAndEditDisabled,
120
+ updateCartShipmentAddress,
121
+ getCartShipmentOptions,
117
122
  } = useCartContext();
118
123
  const saved = getSavedCheckoutAddress(domainId);
119
124
  const cartAddress = cart?.receiverAddress;
@@ -122,6 +127,33 @@ export function ActiveFormShipmentAddressAndNotes() {
122
127
  : saved.address || {};
123
128
  const receiverNotes = cart?.receiverNotes || saved.receiverNotes || "";
124
129
  const [addressFieldsOpen, setAddressFieldsOpen] = useState(false);
130
+ const hydratedShipmentOptionsRef = useRef(false);
131
+
132
+ useEffect(() => {
133
+ if (hydratedShipmentOptionsRef.current || !cart?.id) return;
134
+ const draft = getSavedCheckoutAddress(domainId);
135
+ const action = checkoutShipmentHydrationAction({
136
+ cartAddress: cart?.receiverAddress,
137
+ savedAddress: draft.address,
138
+ shipmentGroups: cart?.shipmentGroups,
139
+ });
140
+ if (!action) return;
141
+ hydratedShipmentOptionsRef.current = true;
142
+ if (action === "apply-saved-address" && draft.address) {
143
+ updateCartShipmentAddress(draft.address);
144
+ return;
145
+ }
146
+ if (action === "refresh-quotes") {
147
+ getCartShipmentOptions?.();
148
+ }
149
+ }, [
150
+ cart?.id,
151
+ cart?.receiverAddress,
152
+ cart?.shipmentGroups,
153
+ domainId,
154
+ getCartShipmentOptions,
155
+ updateCartShipmentAddress,
156
+ ]);
125
157
 
126
158
  async function saveCartShipmentAddressAndGoToNextTab(values: any) {
127
159
  const { receiverAddress: address, receiverNotes } = values;
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
3
3
  import { ButtonPay } from '../buttons';
4
4
  import FormStripeCardFields from './StripeCardFields';
5
5
  import {
6
+ fetchTestPublishableKey,
6
7
  stripeCardFormSubmit,
7
8
  stripeInitPromise,
8
9
  stripePaymentButtonSubmit,
@@ -28,6 +29,7 @@ function StripeCardForm() {
28
29
  const { domain } = cart;
29
30
  const [error, setError] = useState(({} as any));
30
31
  const company = domain.company;
32
+ const isTestCheckout = Boolean(cart.isTest);
31
33
  const hasCompanyPubKey = Boolean(company.isStripeValid && companyStripePubKeyOrTestPubKey(company));
32
34
  const companyPubKey = hasCompanyPubKey ? companyStripePubKeyOrTestPubKey(company) : '';
33
35
  const canUseConnect = !!company.stripeAccountId;
@@ -66,13 +68,17 @@ function StripeCardForm() {
66
68
  setError({message: e.errorMessage || e.message || 'Unable to process card.'});
67
69
  }
68
70
  }
69
- const [stripePublicKey, setStripePublicKey] = useState(companyPubKey);
71
+ const [stripePublicKey, setStripePublicKey] = useState(isTestCheckout ? '' : companyPubKey);
70
72
  useEffect(() => {
73
+ if (isTestCheckout) {
74
+ fetchTestPublishableKey(url).then(setStripePublicKey).catch(() => setStripePublicKey(''));
75
+ return;
76
+ }
71
77
  if (!stripePublicKey && canUseConnect) stripeInitPromise(url).then(setStripePublicKey)
72
- }, [stripePublicKey, canUseConnect]);
78
+ }, [stripePublicKey, canUseConnect, isTestCheckout, url]);
73
79
  return (
74
80
  <>
75
- {company.isTesting && badgeTestMode}
81
+ {!isTestCheckout && company.isTesting && badgeTestMode}
76
82
  <FormStripeCardFields
77
83
  doStripePayment={doStripePayment}
78
84
  doStripePaymentRequestForButton={doStripePaymentRequestForButton}
@@ -1,18 +1,102 @@
1
1
  import * as React from 'react';
2
+ import { useEffect, useState } from 'react';
2
3
  import { StripePaymentForm, StripePaymentGate } from '@merchi/invoice';
3
4
  import LegacyStripeCardForm from './LegacyStripeCardForm';
5
+ import { completeTestPayment, fetchTestPublishableKey } from './actions';
4
6
  import { useCartContext } from '../CartProvider';
5
7
  import { tabIdPaymentSuccess } from '../utilities/tabs';
8
+ import { TEST_CARD_NUMBER } from '../utilities/test_checkout';
9
+
10
+ const testCheckoutHint = (
11
+ <div style={{marginBottom: '12px'}}>
12
+ <div style={{color: 'red', fontWeight: 600}}>Test checkout</div>
13
+ <p style={{margin: '4px 0 0', fontSize: '13px'}}>
14
+ No real charge. Use card {TEST_CARD_NUMBER}, any future expiry, any CVC,
15
+ any ZIP.
16
+ </p>
17
+ </div>
18
+ );
6
19
 
7
20
  export default function StripeCardForm() {
8
- const { alertError, apiUrl, cart, setActiveTabIndex, setInvoiceJson } = useCartContext();
9
- return <StripePaymentGate engine={cart.stripePaymentEngine} apiUrl={apiUrl || 'https://api.merchi.co/v6/'} resource="cart" resourceId={cart.id}
10
- resourceToken={cart.token || cart.cartToken} legacy={<LegacyStripeCardForm />}>
11
- <StripePaymentForm apiUrl={apiUrl || 'https://api.merchi.co/v6/'} resource="cart"
12
- resourceId={cart.id} resourceToken={cart.token || cart.cartToken} onError={alertError}
13
- onSuccess={invoice => {
21
+ const {
22
+ alertError,
23
+ apiUrl,
24
+ cart,
25
+ classNameBtnPay,
26
+ setActiveTabIndex,
27
+ setInvoiceJson,
28
+ } = useCartContext();
29
+ const isTestCheckout = Boolean(cart.isTest);
30
+ const url = apiUrl || 'https://api.merchi.co/v6/';
31
+ const [testKeyMissing, setTestKeyMissing] = useState(false);
32
+ const [loadingTestPayment, setLoadingTestPayment] = useState(false);
33
+
34
+ useEffect(() => {
35
+ if (!isTestCheckout) {
36
+ setTestKeyMissing(false);
37
+ return;
38
+ }
39
+ fetchTestPublishableKey(url)
40
+ .then(() => setTestKeyMissing(false))
41
+ .catch(() => setTestKeyMissing(true));
42
+ }, [isTestCheckout, url]);
43
+
44
+ async function doCompleteTestPayment() {
45
+ setLoadingTestPayment(true);
46
+ try {
47
+ const invoice = await completeTestPayment(url, cart);
14
48
  setInvoiceJson(invoice);
15
49
  setActiveTabIndex(tabIdPaymentSuccess);
16
- }} />
17
- </StripePaymentGate>;
50
+ } catch (e: any) {
51
+ alertError(e.message);
52
+ } finally {
53
+ setLoadingTestPayment(false);
54
+ }
55
+ }
56
+
57
+ if (isTestCheckout && testKeyMissing) {
58
+ return (
59
+ <>
60
+ {testCheckoutHint}
61
+ <p style={{fontSize: '13px', marginBottom: '12px'}}>
62
+ Stripe test keys are not configured. Complete this as a test order
63
+ without a card.
64
+ </p>
65
+ <button
66
+ type='button'
67
+ className={classNameBtnPay}
68
+ disabled={loadingTestPayment}
69
+ onClick={doCompleteTestPayment}
70
+ >
71
+ {loadingTestPayment ? 'Completing…' : 'Complete test order'}
72
+ </button>
73
+ </>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <>
79
+ {isTestCheckout && testCheckoutHint}
80
+ <StripePaymentGate
81
+ engine={isTestCheckout ? 'legacy' : cart.stripePaymentEngine}
82
+ apiUrl={url}
83
+ resource="cart"
84
+ resourceId={cart.id}
85
+ resourceToken={cart.token || cart.cartToken}
86
+ legacy={<LegacyStripeCardForm />}
87
+ >
88
+ <StripePaymentForm
89
+ apiUrl={url}
90
+ resource="cart"
91
+ resourceId={cart.id}
92
+ resourceToken={cart.token || cart.cartToken}
93
+ onError={alertError}
94
+ onSuccess={invoice => {
95
+ setInvoiceJson(invoice);
96
+ setActiveTabIndex(tabIdPaymentSuccess);
97
+ }}
98
+ />
99
+ </StripePaymentGate>
100
+ </>
101
+ );
18
102
  }
@@ -0,0 +1,50 @@
1
+ import { completeTestPayment, fetchTestPublishableKey } from './actions';
2
+
3
+ describe('test checkout stripe actions', () => {
4
+ const originalFetch = global.fetch;
5
+
6
+ afterEach(() => {
7
+ global.fetch = originalFetch;
8
+ });
9
+
10
+ it('loads the master test publishable key', async () => {
11
+ global.fetch = jest.fn().mockResolvedValue({
12
+ ok: true,
13
+ json: async () => ({ key: 'pk_test_master' }),
14
+ }) as any;
15
+
16
+ await expect(fetchTestPublishableKey('https://api.test/v6/')).resolves.toBe(
17
+ 'pk_test_master'
18
+ );
19
+ expect(global.fetch).toHaveBeenCalledWith(
20
+ 'https://api.test/v6/stripe/test_publishable_key/',
21
+ { method: 'GET', mode: 'cors' }
22
+ );
23
+ });
24
+
25
+ it('fails over when the test-key request is rejected', async () => {
26
+ global.fetch = jest.fn().mockResolvedValue({
27
+ ok: false,
28
+ json: async () => ({ message: 'missing' }),
29
+ }) as any;
30
+
31
+ await expect(fetchTestPublishableKey('https://api.test/v6/')).rejects.toThrow(
32
+ 'Stripe test keys are not configured'
33
+ );
34
+ });
35
+
36
+ it('completes a no-Stripe test payment with the cart token', async () => {
37
+ global.fetch = jest.fn().mockResolvedValue({
38
+ ok: true,
39
+ json: async () => ({ invoice: { id: 9, isTest: true } }),
40
+ }) as any;
41
+
42
+ await expect(
43
+ completeTestPayment('https://api.test/v6/', { id: 4, token: 'tok' })
44
+ ).resolves.toEqual({ id: 9, isTest: true });
45
+ expect(global.fetch).toHaveBeenCalledWith(
46
+ 'https://api.test/v6/carts/4/complete_test_payment/?cart_token=tok',
47
+ { method: 'POST', mode: 'cors' }
48
+ );
49
+ });
50
+ });