@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.
- package/esm/CartProvider.js +58 -20
- package/esm/forms/FormShipmentAddressAndNotes.js +24 -3
- package/esm/stripe/LegacyStripeCardForm.js +11 -4
- package/esm/stripe/StripeCardForm.js +122 -16
- package/esm/stripe/actions.js +155 -57
- package/esm/stripe/actions.test.js +120 -0
- package/esm/utilities/cookie.js +15 -12
- package/esm/utilities/index.js +2 -0
- package/esm/utilities/job_source.js +139 -0
- package/esm/utilities/job_source.test.js +75 -0
- package/esm/utilities/shipment.js +22 -0
- package/esm/utilities/shipment.test.js +60 -0
- package/esm/utilities/test_checkout.js +25 -0
- package/esm/utilities/test_checkout.test.js +31 -0
- package/lib/CartProvider.js +58 -20
- package/lib/forms/FormShipmentAddressAndNotes.js +22 -1
- package/lib/stripe/LegacyStripeCardForm.js +10 -3
- package/lib/stripe/StripeCardForm.js +123 -17
- package/lib/stripe/actions.d.ts +2 -0
- package/lib/stripe/actions.js +157 -57
- package/lib/stripe/actions.test.d.ts +1 -0
- package/lib/utilities/cookie.d.ts +3 -3
- package/lib/utilities/cookie.js +15 -12
- package/lib/utilities/index.d.ts +2 -0
- package/lib/utilities/index.js +22 -0
- package/lib/utilities/job_source.d.ts +23 -0
- package/lib/utilities/job_source.js +152 -0
- package/lib/utilities/job_source.test.d.ts +1 -0
- package/lib/utilities/shipment.d.ts +15 -0
- package/lib/utilities/shipment.js +24 -0
- package/lib/utilities/shipment.test.d.ts +1 -0
- package/lib/utilities/test_checkout.d.ts +6 -0
- package/lib/utilities/test_checkout.js +34 -0
- package/lib/utilities/test_checkout.test.d.ts +1 -0
- package/package.json +8 -5
- package/src/CartProvider.tsx +42 -4
- package/src/forms/FormShipmentAddressAndNotes.tsx +34 -2
- package/src/stripe/LegacyStripeCardForm.tsx +9 -3
- package/src/stripe/StripeCardForm.tsx +92 -8
- package/src/stripe/actions.test.ts +50 -0
- package/src/stripe/actions.ts +41 -0
- package/src/utilities/cookie.ts +9 -6
- package/src/utilities/index.ts +2 -0
- package/src/utilities/job_source.test.ts +86 -0
- package/src/utilities/job_source.ts +165 -0
- package/src/utilities/shipment.test.ts +68 -0
- package/src/utilities/shipment.ts +34 -0
- package/src/utilities/test_checkout.test.ts +39 -0
- package/src/utilities/test_checkout.ts +27 -0
package/src/stripe/actions.ts
CHANGED
|
@@ -7,6 +7,47 @@ export async function stripeInitPromise(apiUrl: string) {
|
|
|
7
7
|
).then((response: any) => response.key);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export async function fetchTestPublishableKey(apiUrl: string) {
|
|
11
|
+
const response: any = await fetch(
|
|
12
|
+
`${apiUrl}stripe/test_publishable_key/`,
|
|
13
|
+
{ method: 'GET', mode: 'cors' }
|
|
14
|
+
);
|
|
15
|
+
if (response && typeof response.json === 'function') {
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new Error('Stripe test keys are not configured');
|
|
18
|
+
}
|
|
19
|
+
const data = await response.json();
|
|
20
|
+
if (!data || !data.key) {
|
|
21
|
+
throw new Error('Stripe test keys are not configured');
|
|
22
|
+
}
|
|
23
|
+
return data.key;
|
|
24
|
+
}
|
|
25
|
+
if (!response || !response.key) {
|
|
26
|
+
throw new Error('Stripe test keys are not configured');
|
|
27
|
+
}
|
|
28
|
+
return response.key;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function completeTestPayment(apiUrl: string, cart: any) {
|
|
32
|
+
const query = new URLSearchParams({ cart_token: cart.token }).toString();
|
|
33
|
+
const response = await fetch(
|
|
34
|
+
`${apiUrl}carts/${cart.id}/complete_test_payment/?${query}`,
|
|
35
|
+
{ method: 'POST', mode: 'cors' }
|
|
36
|
+
);
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
let message = 'Unable to complete the test order.';
|
|
39
|
+
try {
|
|
40
|
+
const data = await response.json();
|
|
41
|
+
message = data.message || data.errorMessage || message;
|
|
42
|
+
} catch {
|
|
43
|
+
// keep default
|
|
44
|
+
}
|
|
45
|
+
throw new Error(message);
|
|
46
|
+
}
|
|
47
|
+
const data = await response.json();
|
|
48
|
+
return data.invoice;
|
|
49
|
+
}
|
|
50
|
+
|
|
10
51
|
async function createPaymentIntent(apiUrl: string, cart: any, paymentMethodType?: string) {
|
|
11
52
|
const { id, token } = cart;
|
|
12
53
|
const fetchOptions: any = {
|
package/src/utilities/cookie.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isBrowser } from "browser-or-node";
|
|
2
|
+
import { cartCookieName, readTestCheckoutFlag } from "./test_checkout";
|
|
2
3
|
|
|
3
4
|
export async function getCookie(name: string, defaultValue: any) {
|
|
4
5
|
if (!isBrowser) {
|
|
@@ -22,13 +23,14 @@ export async function getCookie(name: string, defaultValue: any) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
export async function getCartCookie(domainId: number | string) {
|
|
26
|
-
const
|
|
26
|
+
export async function getCartCookie(domainId: number | string, isTest?: boolean) {
|
|
27
|
+
const test = isTest ?? readTestCheckoutFlag();
|
|
28
|
+
const idAndToken: string = await getCookie(cartCookieName(domainId, test), null);
|
|
27
29
|
return idAndToken ? idAndToken.split(',') : null;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
export async function getCartCookieToken(domainId: number | string) {
|
|
31
|
-
const cartIdAndToken = domainId ? await getCartCookie(Number(domainId)) : undefined;
|
|
32
|
+
export async function getCartCookieToken(domainId: number | string, isTest?: boolean) {
|
|
33
|
+
const cartIdAndToken = domainId ? await getCartCookie(Number(domainId), isTest) : undefined;
|
|
32
34
|
return cartIdAndToken && cartIdAndToken[1] || undefined;
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -52,7 +54,8 @@ async function setSessionCookie(name: string, value: any, domain?: any) {
|
|
|
52
54
|
document.cookie = cookie;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
export const setCartCookie = async (storeId: number, cart: any, domain?: any) => {
|
|
57
|
+
export const setCartCookie = async (storeId: number, cart: any, domain?: any, isTest?: boolean) => {
|
|
58
|
+
const test = isTest ?? (cart ? Boolean(cart.isTest) : readTestCheckoutFlag());
|
|
56
59
|
const cookieValue = cart ? cart.id + ',' + cart.token : '';
|
|
57
|
-
await setSessionCookie(
|
|
60
|
+
await setSessionCookie(cartCookieName(storeId, test), cookieValue, domain);
|
|
58
61
|
};
|
package/src/utilities/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ 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';
|
|
12
13
|
export * from './tabs';
|
|
14
|
+
export * from './test_checkout';
|
|
13
15
|
export * from './user';
|
|
14
16
|
export * from './variations';
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkoutShipmentHydrationAction,
|
|
3
|
+
shipmentGroupsHaveQuotes,
|
|
4
|
+
} from './shipment';
|
|
5
|
+
|
|
6
|
+
const savedAddress = {
|
|
7
|
+
lineOne: '32 Bridge St',
|
|
8
|
+
city: 'Brighton',
|
|
9
|
+
state: 'VIC',
|
|
10
|
+
postcode: '3186',
|
|
11
|
+
country: 'AU',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('shipmentGroupsHaveQuotes', () => {
|
|
15
|
+
it('is false when groups are missing or empty', () => {
|
|
16
|
+
expect(shipmentGroupsHaveQuotes(undefined)).toBe(false);
|
|
17
|
+
expect(shipmentGroupsHaveQuotes([])).toBe(false);
|
|
18
|
+
expect(shipmentGroupsHaveQuotes([{ quotes: [] }])).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('is true when any group has quotes', () => {
|
|
22
|
+
expect(
|
|
23
|
+
shipmentGroupsHaveQuotes([{ quotes: [] }, { quotes: [{ id: 1 }] }])
|
|
24
|
+
).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('checkoutShipmentHydrationAction', () => {
|
|
29
|
+
it('applies a localStorage address when the cart has none', () => {
|
|
30
|
+
expect(
|
|
31
|
+
checkoutShipmentHydrationAction({
|
|
32
|
+
cartAddress: {},
|
|
33
|
+
savedAddress,
|
|
34
|
+
shipmentGroups: [],
|
|
35
|
+
})
|
|
36
|
+
).toBe('apply-saved-address');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('refreshes quotes when the cart already has an address but no options', () => {
|
|
40
|
+
expect(
|
|
41
|
+
checkoutShipmentHydrationAction({
|
|
42
|
+
cartAddress: savedAddress,
|
|
43
|
+
savedAddress,
|
|
44
|
+
shipmentGroups: [],
|
|
45
|
+
})
|
|
46
|
+
).toBe('refresh-quotes');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('does nothing when quotes are already available', () => {
|
|
50
|
+
expect(
|
|
51
|
+
checkoutShipmentHydrationAction({
|
|
52
|
+
cartAddress: savedAddress,
|
|
53
|
+
savedAddress,
|
|
54
|
+
shipmentGroups: [{ quotes: [{ id: 9 }] }],
|
|
55
|
+
})
|
|
56
|
+
).toBe(null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does nothing when neither cart nor localStorage has an address', () => {
|
|
60
|
+
expect(
|
|
61
|
+
checkoutShipmentHydrationAction({
|
|
62
|
+
cartAddress: {},
|
|
63
|
+
savedAddress: {},
|
|
64
|
+
shipmentGroups: [],
|
|
65
|
+
})
|
|
66
|
+
).toBe(null);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { addressHasContent, SavedCheckoutAddress } from './local_storage';
|
|
2
|
+
|
|
1
3
|
export const shipmentFormId = '_shipment-address-form-id';
|
|
2
4
|
|
|
3
5
|
export function cartItemsNeedShipment(cart: any) {
|
|
@@ -9,3 +11,35 @@ export function cartItemsNeedShipment(cart: any) {
|
|
|
9
11
|
}
|
|
10
12
|
return false;
|
|
11
13
|
}
|
|
14
|
+
|
|
15
|
+
export function shipmentGroupsHaveQuotes(groups?: any[] | null) {
|
|
16
|
+
if (!groups?.length) return false;
|
|
17
|
+
return groups.some(
|
|
18
|
+
(group) => Array.isArray(group?.quotes) && group.quotes.length > 0
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CheckoutShipmentHydrationAction =
|
|
23
|
+
| 'apply-saved-address'
|
|
24
|
+
| 'refresh-quotes'
|
|
25
|
+
| null;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* When checkout hydrates a saved address (localStorage) the form shows it,
|
|
29
|
+
* but quotes are only fetched after the user edits the field. Returning
|
|
30
|
+
* `apply-saved-address` means the cart still needs that address applied so
|
|
31
|
+
* quotes can be generated; `refresh-quotes` means the cart already has an
|
|
32
|
+
* address but no usable shipment options.
|
|
33
|
+
*/
|
|
34
|
+
export function checkoutShipmentHydrationAction(options: {
|
|
35
|
+
cartAddress?: SavedCheckoutAddress | null;
|
|
36
|
+
savedAddress?: SavedCheckoutAddress | null;
|
|
37
|
+
shipmentGroups?: any[] | null;
|
|
38
|
+
}): CheckoutShipmentHydrationAction {
|
|
39
|
+
const hasCartAddress = addressHasContent(options.cartAddress);
|
|
40
|
+
const hasSavedAddress = addressHasContent(options.savedAddress);
|
|
41
|
+
const hasQuotes = shipmentGroupsHaveQuotes(options.shipmentGroups);
|
|
42
|
+
if (!hasCartAddress && hasSavedAddress) return 'apply-saved-address';
|
|
43
|
+
if (hasCartAddress && !hasQuotes) return 'refresh-quotes';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TEST_CARD_NUMBER,
|
|
3
|
+
TEST_CHECKOUT_QUERY,
|
|
4
|
+
TEST_CHECKOUT_STORAGE_KEY,
|
|
5
|
+
cartCookieName,
|
|
6
|
+
readTestCheckoutFlag,
|
|
7
|
+
} from './test_checkout';
|
|
8
|
+
|
|
9
|
+
describe('test checkout helpers', () => {
|
|
10
|
+
const originalLocation = window.location;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
window.localStorage.clear();
|
|
14
|
+
delete (window as any).location;
|
|
15
|
+
(window as any).location = { search: '' };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
(window as any).location = originalLocation;
|
|
20
|
+
window.localStorage.clear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('builds a separate cookie name for test carts', () => {
|
|
24
|
+
expect(cartCookieName(12)).toBe('cart-12');
|
|
25
|
+
expect(cartCookieName(12, true)).toBe('cart-12-test');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('persists the query flag and exposes the test card number', () => {
|
|
29
|
+
(window as any).location = { search: `?${TEST_CHECKOUT_QUERY}=1` };
|
|
30
|
+
expect(readTestCheckoutFlag()).toBe(true);
|
|
31
|
+
expect(window.localStorage.getItem(TEST_CHECKOUT_STORAGE_KEY)).toBe('1');
|
|
32
|
+
expect(TEST_CARD_NUMBER).toBe('4242 4242 4242 4242');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads the stored flag when the query is absent', () => {
|
|
36
|
+
window.localStorage.setItem(TEST_CHECKOUT_STORAGE_KEY, '1');
|
|
37
|
+
expect(readTestCheckoutFlag()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const TEST_CHECKOUT_QUERY = 'merchi_test_checkout';
|
|
2
|
+
export const TEST_CHECKOUT_STORAGE_KEY = 'merchi_test_checkout';
|
|
3
|
+
export const TEST_CARD_NUMBER = '4242 4242 4242 4242';
|
|
4
|
+
|
|
5
|
+
export function readTestCheckoutFlag(): boolean {
|
|
6
|
+
if (typeof window === 'undefined') {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
const params = new URLSearchParams(window.location.search);
|
|
11
|
+
if (params.get(TEST_CHECKOUT_QUERY) === '1') {
|
|
12
|
+
window.localStorage.setItem(TEST_CHECKOUT_STORAGE_KEY, '1');
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return window.localStorage.getItem(TEST_CHECKOUT_STORAGE_KEY) === '1';
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function persistTestCheckoutFlag(): boolean {
|
|
22
|
+
return readTestCheckoutFlag();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cartCookieName(domainId: number | string, isTest = false): string {
|
|
26
|
+
return isTest ? `cart-${domainId}-test` : `cart-${domainId}`;
|
|
27
|
+
}
|