Skip to content

Commit 696ccdc

Browse files
committed
Add useShopifyEventHandlers hook for checkout event handling
- Provides standardized event handlers with debug logging - Includes handlers for press, fail, complete, cancel, render state changes, pixel events, and link clicks - Integrates with existing cart context for order completion cleanup - Can be used independently of any specific checkout features
1 parent 7bdeb91 commit 696ccdc

6 files changed

Lines changed: 311 additions & 6 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/*
2+
MIT License
3+
4+
Copyright 2023 - Present, Shopify Inc.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
24+
import Foundation
25+
import ShopifyCheckoutSheetKit
26+
27+
/**
28+
* Shared event serialization utilities for converting ShopifyCheckoutSheetKit events
29+
* to React Native compatible dictionaries.
30+
*/
31+
class ShopifyEventSerialization {
32+
33+
/**
34+
* Encodes a Codable object to a JSON dictionary for React Native bridge.
35+
*/
36+
static func encodeToJSON(from value: Codable) -> [String: Any] {
37+
let encoder = JSONEncoder()
38+
39+
do {
40+
let jsonData = try encoder.encode(value)
41+
if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] {
42+
return jsonObject
43+
}
44+
} catch {
45+
print("Error encoding to JSON object: \(error)")
46+
}
47+
return [:]
48+
}
49+
50+
/**
51+
* Converts a JSON string to a dictionary.
52+
*/
53+
static func stringToJSON(from value: String?) -> [String: Any]? {
54+
guard let data = value?.data(using: .utf8, allowLossyConversion: false) else { return [:] }
55+
do {
56+
return try JSONSerialization.jsonObject(with: data, options: .mutableContainers) as? [String: Any]
57+
} catch {
58+
print("Failed to convert string to JSON: \(error)", value ?? "nil")
59+
return [:]
60+
}
61+
}
62+
63+
/**
64+
* Converts a CheckoutCompletedEvent to a React Native compatible dictionary.
65+
*/
66+
static func serialize(checkoutCompletedEvent event: CheckoutCompletedEvent) -> [String: Any] {
67+
return encodeToJSON(from: event)
68+
}
69+
70+
/**
71+
* Converts a PixelEvent to a React Native compatible dictionary.
72+
*/
73+
static func serialize(pixelEvent event: PixelEvent) -> [String: Any] {
74+
switch event {
75+
case .standardEvent(let standardEvent):
76+
let encoded = encodeToJSON(from: standardEvent)
77+
return [
78+
"context": encoded["context"] ?? NSNull(),
79+
"data": encoded["data"] ?? NSNull(),
80+
"id": encoded["id"] ?? NSNull(),
81+
"name": encoded["name"] ?? NSNull(),
82+
"timestamp": encoded["timestamp"] ?? NSNull(),
83+
"type": "STANDARD"
84+
]
85+
86+
case .customEvent(let customEvent):
87+
return [
88+
"context": encodeToJSON(from: customEvent.context),
89+
"customData": stringToJSON(from: customEvent.customData) ?? NSNull(),
90+
"id": customEvent.id,
91+
"name": customEvent.name,
92+
"timestamp": customEvent.timestamp,
93+
"type": "CUSTOM"
94+
]
95+
}
96+
}
97+
98+
static func serialize(clickEvent url: URL) -> [String: URL] {
99+
return ["url": url]
100+
}
101+
102+
/**
103+
* Converts a CheckoutError to a React Native compatible dictionary.
104+
* Handles all specific error types with proper type information.
105+
*/
106+
static func serialize(checkoutError error: CheckoutError) -> [String: Any] {
107+
switch error {
108+
case .checkoutExpired(let message, let code, let recoverable):
109+
return [
110+
"__typename": "CheckoutExpiredError",
111+
"message": message,
112+
"code": code.rawValue,
113+
"recoverable": recoverable
114+
]
115+
116+
case .checkoutUnavailable(let message, let code, let recoverable):
117+
switch code {
118+
case .clientError(let clientErrorCode):
119+
return [
120+
"__typename": "CheckoutClientError",
121+
"message": message,
122+
"code": clientErrorCode.rawValue,
123+
"recoverable": recoverable
124+
]
125+
case .httpError(let statusCode):
126+
return [
127+
"__typename": "CheckoutHTTPError",
128+
"message": message,
129+
"code": "http_error",
130+
"statusCode": statusCode,
131+
"recoverable": recoverable
132+
]
133+
}
134+
135+
case .configurationError(let message, let code, let recoverable):
136+
return [
137+
"__typename": "ConfigurationError",
138+
"message": message,
139+
"code": code.rawValue,
140+
"recoverable": recoverable
141+
]
142+
143+
case .sdkError(let underlying, let recoverable):
144+
return [
145+
"__typename": "InternalError",
146+
"code": "unknown",
147+
"message": underlying.localizedDescription,
148+
"recoverable": recoverable
149+
]
150+
151+
@unknown default:
152+
return [
153+
"__typename": "UnknownError",
154+
"code": "unknown",
155+
"message": error.localizedDescription,
156+
"recoverable": error.isRecoverable
157+
]
158+
}
159+
}
160+
161+
/**
162+
* Converts a RenderState enum to a string for React Native.
163+
*/
164+
static func serialize(renderState state: RenderState) -> [String: String] {
165+
switch state {
166+
case .loading:
167+
return ["state": "loading"]
168+
case .rendered:
169+
return ["state": "rendered"]
170+
case .error:
171+
return ["state": "error"]
172+
@unknown default:
173+
return ["state": "unknown"]
174+
}
175+
}
176+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
MIT License
3+
4+
Copyright 2023 - Present, Shopify Inc.
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*/
23+
24+
import UIKit
25+
26+
// MARK: - UIColor Extensions
27+
28+
extension UIColor {
29+
convenience init(hex: String) {
30+
let hexString: String = hex.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
31+
let start = hexString.index(hexString.startIndex, offsetBy: hexString.hasPrefix("#") ? 1 : 0)
32+
let hexColor = String(hexString[start...])
33+
34+
let scanner = Scanner(string: hexColor)
35+
var hexNumber: UInt64 = 0
36+
37+
if scanner.scanHexInt64(&hexNumber) {
38+
let red = (hexNumber & 0xff0000) >> 16
39+
let green = (hexNumber & 0x00ff00) >> 8
40+
let blue = hexNumber & 0x0000ff
41+
42+
self.init(
43+
red: CGFloat(red) / 0xff,
44+
green: CGFloat(green) / 0xff,
45+
blue: CGFloat(blue) / 0xff,
46+
alpha: 1
47+
)
48+
} else {
49+
self.init(red: 0, green: 0, blue: 0, alpha: 1)
50+
}
51+
}
52+
}

modules/@shopify/checkout-sheet-kit/tests/context.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,9 @@ describe('ShopifyCheckoutSheetContext without provider', () => {
394394
// Test all the noop functions to ensure they don't throw
395395
expect(() => hookValue.addEventListener('close', jest.fn())).not.toThrow();
396396
expect(() => hookValue.removeEventListeners('close')).not.toThrow();
397-
expect(() => hookValue.setConfig({colorScheme: ColorScheme.automatic})).not.toThrow();
397+
expect(() =>
398+
hookValue.setConfig({colorScheme: ColorScheme.automatic}),
399+
).not.toThrow();
398400
expect(() => hookValue.preload('test-url')).not.toThrow();
399401
expect(() => hookValue.present('test-url')).not.toThrow();
400402
expect(() => hookValue.invalidate()).not.toThrow();

modules/@shopify/checkout-sheet-kit/tests/index.test.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const config: Configuration = {
2222
jest.mock('react-native', () => {
2323
let listeners: (typeof jest.fn)[] = [];
2424

25-
const NativeEventEmitter = jest.fn(() => ({
25+
const mockNativeEventEmitter = jest.fn(() => ({
2626
addListener: jest.fn((_, callback) => {
2727
listeners.push(callback);
2828
}),
@@ -43,8 +43,8 @@ jest.mock('react-native', () => {
4343
preloading: true,
4444
};
4545

46-
const ShopifyCheckoutSheetKit = {
47-
eventEmitter: NativeEventEmitter(),
46+
const mockShopifyCheckoutSheetKit = {
47+
eventEmitter: mockNativeEventEmitter(),
4848
version: '0.7.0',
4949
preload: jest.fn(),
5050
present: jest.fn(),
@@ -55,26 +55,34 @@ jest.mock('react-native', () => {
5555
addEventListener: jest.fn(),
5656
removeEventListeners: jest.fn(),
5757
initiateGeolocationRequest: jest.fn(),
58+
configureAcceleratedCheckouts: jest.fn(),
59+
isAcceleratedCheckoutAvailable: jest.fn(),
5860
};
5961

62+
const mockRequireNativeComponent = jest.fn().mockImplementation(() => {
63+
return 'MockNativeComponent';
64+
});
65+
6066
return {
6167
Platform: {
6268
OS: 'ios',
6369
},
6470
PermissionsAndroid: {
6571
requestMultiple: jest.fn(),
6672
},
73+
requireNativeComponent: mockRequireNativeComponent,
6774
_listeners: listeners,
68-
NativeEventEmitter,
75+
NativeEventEmitter: mockNativeEventEmitter,
6976
NativeModules: {
70-
ShopifyCheckoutSheetKit,
77+
ShopifyCheckoutSheetKit: mockShopifyCheckoutSheetKit,
7178
},
7279
};
7380
});
7481

7582
global.console = {
7683
...global.console,
7784
error: jest.fn(),
85+
warn: jest.fn(),
7886
};
7987

8088
describe('ShopifyCheckoutSheetKit', () => {

modules/@shopify/checkout-sheet-kit/tests/linking.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ jest.mock('react-native', () => ({
1111
Platform: {
1212
OS: 'ios',
1313
},
14+
requireNativeComponent: jest.fn().mockImplementation(() => {
15+
const mockComponent = (props: any) => {
16+
// Use React.createElement with plain object instead
17+
const mockReact = jest.requireActual('react');
18+
return mockReact.createElement('View', props);
19+
};
20+
return mockComponent;
21+
}),
1422
}));
1523

1624
describe('Native Module Linking', () => {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {useMemo} from 'react';
2+
3+
import {createDebugLogger} from '../utils';
4+
5+
import {useCart} from '../context/Cart';
6+
import type {
7+
CheckoutCompletedEvent,
8+
CheckoutException,
9+
PixelEvent,
10+
RenderState,
11+
} from '@shopify/checkout-sheet-kit';
12+
import {Linking} from 'react-native';
13+
14+
interface EventHandlers {
15+
onPress?: () => void;
16+
onFail?: (error: CheckoutException) => void;
17+
onComplete?: (event: CheckoutCompletedEvent) => void;
18+
onCancel?: () => void;
19+
onRenderStateChange?: (state: RenderState) => void;
20+
onShouldRecoverFromError?: (error: {message: string}) => boolean;
21+
onWebPixelEvent?: (event: PixelEvent) => void;
22+
onClickLink?: (url: string) => void;
23+
}
24+
25+
export function useShopifyEventHandlers(name?: string): EventHandlers {
26+
const log = createDebugLogger(name ?? '');
27+
const {clearCart} = useCart();
28+
29+
return useMemo(() => {
30+
return {
31+
onPress: () => {
32+
log('onPress');
33+
},
34+
onFail: error => {
35+
log('onFail', error);
36+
},
37+
onComplete: event => {
38+
log('onComplete', event.orderDetails.id);
39+
clearCart();
40+
},
41+
onCancel: () => {
42+
log('onCancel');
43+
},
44+
onRenderStateChange: state => {
45+
log('onRenderStateChange', state);
46+
},
47+
onWebPixelEvent: event => {
48+
log('onWebPixelEvent', event.name);
49+
},
50+
onClickLink: async url => {
51+
log('onClickLink', url);
52+
53+
if (await Linking.canOpenURL(url)) {
54+
await Linking.openURL(url);
55+
}
56+
},
57+
};
58+
}, [log, clearCart]);
59+
}

0 commit comments

Comments
 (0)