Skip to content

Commit f59f299

Browse files
committed
feat: Accelerated Checkouts
1 parent c887bcf commit f59f299

29 files changed

Lines changed: 1579 additions & 122 deletions

modules/@shopify/checkout-sheet-kit/RNShopifyCheckoutSheetKit.podspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ Pod::Spec.new do |s|
2020
s.source_files = "ios/*.{h,m,mm,swift}"
2121

2222
s.dependency "React-Core"
23-
s.dependency "ShopifyCheckoutSheetKit", "~> 3.1.2"
23+
s.dependency "ShopifyCheckoutSheetKit", "~> 3.4.0-rc.2"
24+
s.dependency "ShopifyCheckoutSheetKit/AcceleratedCheckouts", "~> 3.4.0-rc.2"
2425

2526
if fabric_enabled
2627
install_modules_dependencies(s)
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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 PassKit
26+
import React
27+
import ShopifyCheckoutSheetKit
28+
import SwiftUI
29+
import UIKit
30+
31+
// MARK: - AcceleratedCheckout Components
32+
33+
@available(iOS 17.0, *)
34+
class AcceleratedCheckoutConfiguration {
35+
static let shared = AcceleratedCheckoutConfiguration()
36+
var configuration: ShopifyAcceleratedCheckouts.Configuration?
37+
var wallets: [Wallet] = [Wallet.shopPay, Wallet.applePay]
38+
39+
private init() {
40+
setupApplePay()
41+
}
42+
43+
private func setupApplePay() {
44+
// Configure Apple Pay environment
45+
// This should be called during app initialization
46+
if let merchantID = Bundle.main.object(forInfoDictionaryKey: "ApplePayMerchantID") as? String {
47+
// Apple Pay is configured via merchant ID in Info.plist
48+
// The actual Apple Pay setup is handled by the ShopifyAcceleratedCheckouts framework
49+
print("✅ Apple Pay configured with Merchant ID: \(merchantID)")
50+
} else {
51+
print("⚠️ Apple Pay Merchant ID not found in Info.plist. Add 'ApplePayMerchantID' key to enable Apple Pay.")
52+
}
53+
}
54+
}
55+
56+
@objc(RCTAcceleratedCheckoutButtonsManager)
57+
class RCTAcceleratedCheckoutButtonsManager: RCTViewManager {
58+
override func view() -> UIView! {
59+
if #available(iOS 17.0, *) {
60+
return RCTAcceleratedCheckoutButtonsView()
61+
}
62+
63+
// Return an empty view for iOS < 17.0 (silent fallback)
64+
return UIView()
65+
}
66+
67+
override static func requiresMainQueueSetup() -> Bool {
68+
return true
69+
}
70+
71+
override func constantsToExport() -> [AnyHashable: Any]! {
72+
return [:]
73+
}
74+
}
75+
76+
@available(iOS 17.0, *)
77+
class RCTAcceleratedCheckoutButtonsView: UIView {
78+
private var hostingController: UIHostingController<AnyView>?
79+
private var configuration: ShopifyAcceleratedCheckouts.Configuration?
80+
private weak var parentViewController: UIViewController?
81+
82+
@objc var cartId: String? {
83+
didSet {
84+
updateView()
85+
}
86+
}
87+
88+
@objc var variantId: String? {
89+
didSet {
90+
updateView()
91+
}
92+
}
93+
94+
@objc var quantity: NSNumber = 1 {
95+
didSet {
96+
updateView()
97+
}
98+
}
99+
100+
@objc var cornerRadius: NSNumber = 8 {
101+
didSet {
102+
updateView()
103+
}
104+
}
105+
106+
@objc var wallets: [String]? {
107+
didSet {
108+
updateView()
109+
}
110+
}
111+
112+
@objc var onPress: RCTBubblingEventBlock?
113+
@objc var onFail: RCTBubblingEventBlock?
114+
@objc var onComplete: RCTBubblingEventBlock?
115+
@objc var onCancel: RCTBubblingEventBlock?
116+
@objc var onRenderStateChange: RCTBubblingEventBlock?
117+
@objc var onShouldRecoverFromError: RCTDirectEventBlock?
118+
@objc var onWebPixelEvent: RCTBubblingEventBlock?
119+
@objc var onClickLink: RCTBubblingEventBlock?
120+
121+
override init(frame: CGRect) {
122+
super.init(frame: frame)
123+
setupView()
124+
}
125+
126+
required init?(coder: NSCoder) {
127+
super.init(coder: coder)
128+
setupView()
129+
}
130+
131+
private func setupView() {
132+
// Configuration will be set via a static method from the main module
133+
configuration = AcceleratedCheckoutConfiguration.shared.configuration
134+
135+
// Find the parent view controller
136+
DispatchQueue.main.async { [weak self] in
137+
self?.parentViewController = self?.findViewController()
138+
}
139+
140+
updateView()
141+
142+
// Listen for configuration updates
143+
NotificationCenter.default.addObserver(
144+
self,
145+
selector: #selector(configurationUpdated),
146+
name: Notification.Name("AcceleratedCheckoutConfigurationUpdated"),
147+
object: nil
148+
)
149+
}
150+
151+
private func findViewController() -> UIViewController? {
152+
var responder: UIResponder? = self
153+
while let nextResponder = responder?.next {
154+
if let viewController = nextResponder as? UIViewController {
155+
return viewController
156+
}
157+
responder = nextResponder
158+
}
159+
return nil
160+
}
161+
162+
@objc private func configurationUpdated() {
163+
configuration = AcceleratedCheckoutConfiguration.shared.configuration
164+
updateView()
165+
}
166+
167+
private func createCartButtons(
168+
cartId: String,
169+
wallets: [Wallet],
170+
config: ShopifyAcceleratedCheckouts.Configuration,
171+
applePayConfig: ShopifyAcceleratedCheckouts.ApplePayConfiguration
172+
) -> some View {
173+
AcceleratedCheckoutButtons(cartID: cartId)
174+
.wallets(wallets)
175+
.onComplete { [weak self] event in
176+
self?.handleCheckoutCompleted(event)
177+
}
178+
.onFail { [weak self] error in
179+
self?.handleCheckoutFailed(error)
180+
}
181+
.onCancel { [weak self] in
182+
self?.handleCheckoutCancelled()
183+
}
184+
.onRenderStateChange { [weak self] state in
185+
self?.handleRenderStateChange(state)
186+
}
187+
.onClickLink { [weak self] url in
188+
self?.handleClickLink(url)
189+
}
190+
.onWebPixelEvent { [weak self] event in
191+
self?.handleWebPixelEvent(event)
192+
}
193+
.cornerRadius(CGFloat(cornerRadius.doubleValue))
194+
.environment(config)
195+
.environment(applePayConfig)
196+
}
197+
198+
private func createVariantButtons(
199+
variantId: String,
200+
quantity: Int,
201+
wallets: [Wallet],
202+
config: ShopifyAcceleratedCheckouts.Configuration,
203+
applePayConfig: ShopifyAcceleratedCheckouts.ApplePayConfiguration
204+
) -> some View {
205+
AcceleratedCheckoutButtons(variantID: variantId, quantity: quantity)
206+
.wallets(wallets)
207+
.onComplete { [weak self] event in
208+
self?.handleCheckoutCompleted(event)
209+
}
210+
.onFail { [weak self] error in
211+
self?.handleCheckoutFailed(error)
212+
}
213+
.onCancel { [weak self] in
214+
self?.handleCheckoutCancelled()
215+
}
216+
.onRenderStateChange { [weak self] state in
217+
self?.handleRenderStateChange(state)
218+
}
219+
.onClickLink { [weak self] url in
220+
self?.handleClickLink(url)
221+
}
222+
.onWebPixelEvent { [weak self] event in
223+
self?.handleWebPixelEvent(event)
224+
}
225+
.cornerRadius(CGFloat(cornerRadius.doubleValue))
226+
.environment(config)
227+
.environment(applePayConfig)
228+
}
229+
230+
private func updateView() {
231+
// Make sure we have a configuration before creating the view
232+
guard let config = configuration else {
233+
// If no configuration is set yet, show an empty view
234+
if let hostingController {
235+
hostingController.rootView = AnyView(EmptyView())
236+
}
237+
return
238+
}
239+
240+
// Use wallets from props, or fallback to default
241+
let shopifyWallets = wallets.map(convertToShopifyWallets) ?? AcceleratedCheckoutConfiguration.shared.wallets
242+
243+
// Create Apple Pay configuration
244+
let applePayConfig = ShopifyAcceleratedCheckouts.ApplePayConfiguration(
245+
merchantIdentifier: Bundle.main.object(forInfoDictionaryKey: "ApplePayMerchantID") as? String ?? "merchant.com.shopify",
246+
contactFields: [.email, .phone]
247+
)
248+
249+
let swiftUIView: AnyView
250+
251+
if let cartId {
252+
swiftUIView = AnyView(createCartButtons(
253+
cartId: cartId,
254+
wallets: shopifyWallets,
255+
config: config,
256+
applePayConfig: applePayConfig
257+
))
258+
} else if let variantId {
259+
swiftUIView = AnyView(createVariantButtons(
260+
variantId: variantId,
261+
quantity: quantity.intValue,
262+
wallets: shopifyWallets,
263+
config: config,
264+
applePayConfig: applePayConfig
265+
))
266+
} else {
267+
// Empty view if no cart or variant ID is provided
268+
swiftUIView = AnyView(EmptyView())
269+
}
270+
271+
if let hostingController {
272+
hostingController.rootView = swiftUIView
273+
} else {
274+
hostingController = UIHostingController(rootView: swiftUIView)
275+
hostingController?.view.backgroundColor = UIColor.clear
276+
277+
// Ensure the hosting view can receive touch events
278+
hostingController?.view.isUserInteractionEnabled = true
279+
280+
if let hostingView = hostingController?.view {
281+
addSubview(hostingView)
282+
hostingView.translatesAutoresizingMaskIntoConstraints = false
283+
NSLayoutConstraint.activate([
284+
hostingView.topAnchor.constraint(equalTo: topAnchor),
285+
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
286+
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
287+
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor)
288+
])
289+
}
290+
}
291+
292+
// Ensure this view can also receive touch events
293+
isUserInteractionEnabled = true
294+
}
295+
296+
private func convertToShopifyWallets(_ walletStrings: [String]) -> [Wallet] {
297+
return walletStrings.compactMap { walletString in
298+
switch walletString {
299+
case "shopPay":
300+
return .shopPay
301+
case "applePay":
302+
return .applePay
303+
default:
304+
return nil
305+
}
306+
}
307+
}
308+
309+
private func handleCheckoutCompleted(_ event: CheckoutCompletedEvent) {
310+
onComplete?(ShopifyEventSerialization.serialize(checkoutCompletedEvent: event))
311+
}
312+
313+
private func handleCheckoutFailed(_ error: CheckoutError) {
314+
onFail?(ShopifyEventSerialization.serialize(checkoutError: error))
315+
}
316+
317+
private func handleCheckoutCancelled() {
318+
onCancel?([:])
319+
}
320+
321+
private func handleRenderStateChange(_ state: RenderState) {
322+
onRenderStateChange?(ShopifyEventSerialization.serialize(renderState: state))
323+
}
324+
325+
private func handleWebPixelEvent(_ event: PixelEvent) {
326+
onWebPixelEvent?(ShopifyEventSerialization.serialize(pixelEvent: event))
327+
}
328+
329+
private func handleClickLink(_ url: URL) {
330+
onClickLink?(ShopifyEventSerialization.serialize(clickEvent: url))
331+
}
332+
333+
override func layoutSubviews() {
334+
super.layoutSubviews()
335+
hostingController?.view.frame = bounds
336+
}
337+
338+
override var intrinsicContentSize: CGSize {
339+
// Provide a default size for the button
340+
return CGSize(width: UIView.noIntrinsicMetric, height: 50)
341+
}
342+
}

modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,20 @@ enum ShopifyEventSerialization {
156156
]
157157
}
158158
}
159+
160+
/**
161+
* Converts a RenderState enum to a string for React Native.
162+
*/
163+
static func serialize(renderState state: RenderState) -> [String: String] {
164+
switch state {
165+
case .loading:
166+
return ["state": "loading"]
167+
case .rendered:
168+
return ["state": "rendered"]
169+
case .error:
170+
return ["state": "error"]
171+
@unknown default:
172+
return ["state": "unknown"]
173+
}
174+
}
159175
}

modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit-Bridging-Header.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO
2323

2424
#import <React/RCTBridgeModule.h>
2525
#import <React/RCTViewManager.h>
26+
#import <React/RCTEventEmitter.h>
27+
#import <React/RCTUIManager.h>
28+
#import <React/RCTBridge.h>

0 commit comments

Comments
 (0)