@merchi/cart 1.4.5 → 1.4.7

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.
@@ -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
+ }