Skip to content

Commit ea60daa

Browse files
committed
feat: Accelerated Checkouts
1 parent 590f709 commit ea60daa

35 files changed

Lines changed: 1562 additions & 176 deletions

.swiftlint.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ disabled_rules:
88
- opening_brace
99
- function_body_length
1010
- trailing_comma
11+
- function_parameter_count
1112

1213
opt_in_rules:
1314
- array_init

dev.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ up:
2525
2626
packages:
2727
- xcode
28+
- swiftlint
2829

2930
check:
30-
lint_swift: yarn module lint:swift
31+
lint_swift: ./scripts/lint_swift
3132
lint_module: yarn module lint
3233
lint_sample: yarn sample lint
3334

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

Lines changed: 3 additions & 2 deletions
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.3.0"
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)
@@ -38,5 +39,5 @@ Pod::Spec.new do |s|
3839
s.dependency "RCTRequired"
3940
s.dependency "RCTTypeSafety"
4041
s.dependency "ReactCommon/turbomodule/core"
41-
end
42+
end
4243
end
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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(
246+
forInfoDictionaryKey: "ApplePayMerchantID") as? String ?? "merchant.com.shopify",
247+
contactFields: [.email, .phone]
248+
)
249+
250+
let swiftUIView: AnyView
251+
252+
if let cartId {
253+
swiftUIView = AnyView(createCartButtons(
254+
cartId: cartId,
255+
wallets: shopifyWallets,
256+
config: config,
257+
applePayConfig: applePayConfig
258+
))
259+
} else if let variantId {
260+
swiftUIView = AnyView(createVariantButtons(
261+
variantId: variantId,
262+
quantity: quantity.intValue,
263+
wallets: shopifyWallets,
264+
config: config,
265+
applePayConfig: applePayConfig
266+
))
267+
} else {
268+
// Empty view if no cart or variant ID is provided
269+
swiftUIView = AnyView(EmptyView())
270+
}
271+
272+
if let hostingController {
273+
hostingController.rootView = swiftUIView
274+
} else {
275+
hostingController = UIHostingController(rootView: swiftUIView)
276+
hostingController?.view.backgroundColor = UIColor.clear
277+
278+
// Ensure the hosting view can receive touch events
279+
hostingController?.view.isUserInteractionEnabled = true
280+
281+
if let hostingView = hostingController?.view {
282+
addSubview(hostingView)
283+
hostingView.translatesAutoresizingMaskIntoConstraints = false
284+
NSLayoutConstraint.activate([
285+
hostingView.topAnchor.constraint(equalTo: topAnchor),
286+
hostingView.leadingAnchor.constraint(equalTo: leadingAnchor),
287+
hostingView.trailingAnchor.constraint(equalTo: trailingAnchor),
288+
hostingView.bottomAnchor.constraint(equalTo: bottomAnchor)
289+
])
290+
}
291+
}
292+
293+
// Ensure this view can also receive touch events
294+
isUserInteractionEnabled = true
295+
}
296+
297+
private func convertToShopifyWallets(_ walletStrings: [String]) -> [Wallet] {
298+
return walletStrings.compactMap { walletString in
299+
switch walletString {
300+
case "shopPay":
301+
return .shopPay
302+
case "applePay":
303+
return .applePay
304+
default:
305+
return nil
306+
}
307+
}
308+
}
309+
310+
private func handleCheckoutCompleted(_ event: CheckoutCompletedEvent) {
311+
onComplete?(ShopifyEventSerialization.serialize(checkoutCompletedEvent: event))
312+
}
313+
314+
private func handleCheckoutFailed(_ error: CheckoutError) {
315+
onFail?(ShopifyEventSerialization.serialize(checkoutError: error))
316+
}
317+
318+
private func handleCheckoutCancelled() {
319+
onCancel?([:])
320+
}
321+
322+
private func handleRenderStateChange(_ state: RenderState) {
323+
onRenderStateChange?(ShopifyEventSerialization.serialize(renderState: state))
324+
}
325+
326+
private func handleWebPixelEvent(_ event: PixelEvent) {
327+
onWebPixelEvent?(ShopifyEventSerialization.serialize(pixelEvent: event))
328+
}
329+
330+
private func handleClickLink(_ url: URL) {
331+
onClickLink?(ShopifyEventSerialization.serialize(clickEvent: url))
332+
}
333+
334+
override func layoutSubviews() {
335+
super.layoutSubviews()
336+
hostingController?.view.frame = bounds
337+
}
338+
339+
override var intrinsicContentSize: CGSize {
340+
// Provide a default size for the button
341+
return CGSize(width: UIView.noIntrinsicMetric, height: 50)
342+
}
343+
}

0 commit comments

Comments
 (0)