|
| 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 | +} |
0 commit comments