diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEventType.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEventType.java new file mode 100644 index 00000000..91b7cecc --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEventType.java @@ -0,0 +1,59 @@ +/* +MIT License + +Copyright 2023 - Present, Shopify Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +package com.shopify.reactnative.checkoutsheetkit; + +/** + * Registering a new type in this enum will automatically register a channel + * that can trigger an event to be received back in React Native. + * + * For example, you can trigger an event from Java/Kotlin by calling: + * + * RCTCheckoutWebView.sendEvent(CheckoutEventType.ON_START, null); + * + * This will execute the corresponding prop in JavaScript: + * + * {}} // <- Will be executed + * /> + */ +public enum CheckoutEventType { + ON_START("onStart"), + ON_ERROR("onError"), + ON_COMPLETE("onComplete"), + ON_CANCEL("onCancel"), + ON_LINK_CLICK("onLinkClick"), + ON_ADDRESS_CHANGE_START("onAddressChangeStart"), + ON_PAYMENT_METHOD_CHANGE_START("onPaymentMethodChangeStart"), + ON_SUBMIT_START("onSubmitStart"); + + private final String eventName; + + CheckoutEventType(String eventName) { + this.eventName = eventName; + } + + public String getEventName() { + return eventName; + } +} diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java index a8d4004f..cde0e66c 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java @@ -27,7 +27,6 @@ of this software and associated documentation files (the "Software"), to deal import android.net.Uri; import android.os.Handler; import android.os.Looper; -import android.util.AttributeSet; import android.util.Log; import android.widget.FrameLayout; @@ -42,6 +41,7 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutsheetkit.Authentication; import com.shopify.checkoutsheetkit.CheckoutException; +import com.shopify.checkoutsheetkit.CheckoutPaymentMethodChangeStartParams; import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor; import com.shopify.checkoutsheetkit.CheckoutOptions; import com.shopify.checkoutsheetkit.CheckoutWebView; @@ -55,11 +55,14 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutStartEvent; import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart; import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent; -import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStart; -import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStartEvent; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutPaymentMethodChangeStart; + +import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStart; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStartEvent; + import java.util.HashMap; import java.util.Map; @@ -286,18 +289,18 @@ private String getErrorTypeName(CheckoutException error) { } } - private void sendEvent(String eventName, WritableMap params) { + private void sendEvent(CheckoutEventType eventType, WritableMap params) { ReactContext reactContext = this.context.getReactApplicationContext(); int viewId = getId(); EventDispatcher eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, viewId); if (eventDispatcher == null) { - Log.w(TAG, "Cannot send event '" + eventName + "': EventDispatcher not available (viewId=" + viewId + ")"); + Log.w(TAG, "Cannot send event '" + eventType.getEventName() + "': EventDispatcher not available (viewId=" + viewId + ")"); return; } int surfaceId = UIManagerHelper.getSurfaceId(reactContext); - eventDispatcher.dispatchEvent(new CheckoutEvent(surfaceId, viewId, eventName, params)); + eventDispatcher.dispatchEvent(new CheckoutEvent(surfaceId, viewId, eventType.getEventName(), params)); } private class InlineCheckoutEventProcessor extends DefaultCheckoutEventProcessor { @@ -310,7 +313,7 @@ public InlineCheckoutEventProcessor(android.content.Context context) { public void onStart(@NonNull CheckoutStartEvent event) { try { WritableMap data = serializeToWritableMap(event); - sendEvent("onStart", data); + sendEvent(CheckoutEventType.ON_START, data); } catch (Exception e) { Log.e(TAG, "Error processing start event", e); } @@ -320,7 +323,7 @@ public void onStart(@NonNull CheckoutStartEvent event) { public void onComplete(@NonNull CheckoutCompleteEvent event) { try { WritableMap data = serializeToWritableMap(event); - sendEvent("onComplete", data); + sendEvent(CheckoutEventType.ON_COMPLETE, data); } catch (Exception e) { Log.e(TAG, "Error processing complete event", e); } @@ -328,12 +331,12 @@ public void onComplete(@NonNull CheckoutCompleteEvent event) { @Override public void onFail(@NonNull CheckoutException error) { - sendEvent("onError", buildErrorMap(error)); + sendEvent(CheckoutEventType.ON_ERROR, buildErrorMap(error)); } @Override public void onCancel() { - sendEvent("onCancel", null); + sendEvent(CheckoutEventType.ON_CANCEL, null); } @Override @@ -347,13 +350,28 @@ public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) { eventData.put("addressType", params.getAddressType()); eventData.put("cart", params.getCart()); - sendEvent("onAddressChangeStart", serializeToWritableMap(eventData)); + sendEvent(CheckoutEventType.ON_ADDRESS_CHANGE_START, serializeToWritableMap(eventData)); } catch (Exception e) { Log.e(TAG, "Error processing address change start event", e); } } @Override + public void onPaymentMethodChangeStart(@NonNull CheckoutPaymentMethodChangeStart event) { + try { + CheckoutPaymentMethodChangeStartParams params = event.getParams(); + + Map eventData = new HashMap<>(); + eventData.put("id", event.getId()); + eventData.put("type", "paymentMethodChangeStart"); + eventData.put("cart", params.getCart()); + + sendEvent(CheckoutEventType.ON_PAYMENT_METHOD_CHANGE_START, serializeToWritableMap(eventData)); + } catch (Exception e) { + Log.e(TAG, "Error processing address change start event", e); + } + } + public void onSubmitStart(@NonNull CheckoutSubmitStart event) { try { CheckoutSubmitStartEvent params = event.getParams(); @@ -367,7 +385,7 @@ public void onSubmitStart(@NonNull CheckoutSubmitStart event) { checkoutData.put("id", params.getCheckout().getId()); eventData.put("checkout", checkoutData); - sendEvent("onSubmitStart", serializeToWritableMap(eventData)); + sendEvent(CheckoutEventType.ON_SUBMIT_START, serializeToWritableMap(eventData)); } catch (Exception e) { Log.e(TAG, "Error processing submit start event", e); } @@ -377,7 +395,7 @@ public void onSubmitStart(@NonNull CheckoutSubmitStart event) { public void onLinkClick(@NonNull Uri uri) { WritableMap params = Arguments.createMap(); params.putString("url", uri.toString()); - sendEvent("onLinkClick", params); + sendEvent(CheckoutEventType.ON_LINK_CLICK, params); } } } diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java index 45b7f93d..4a4522da 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java @@ -85,22 +85,15 @@ public void receiveCommand(@NonNull RCTCheckoutWebView view, String commandId, @ @Override public Map getExportedCustomDirectEventTypeConstants() { Map events = new HashMap<>(); - events.put("onStart", createEventMap("onStart")); - events.put("onError", createEventMap("onError")); - events.put("onComplete", createEventMap("onComplete")); - events.put("onCancel", createEventMap("onCancel")); - events.put("onLinkClick", createEventMap("onLinkClick")); - events.put("onAddressChangeStart", createEventMap("onAddressChangeStart")); - events.put("onSubmitStart", createEventMap("onSubmitStart")); + for (CheckoutEventType eventType : CheckoutEventType.values()) { + String eventName = eventType.getEventName(); + Map event = new HashMap<>(); + event.put("registrationName", eventName); + events.put(eventName, event); + } return events; } - private Map createEventMap(String eventName) { - Map event = new HashMap<>(); - event.put("registrationName", eventName); - return event; - } - @Override public void onDropViewInstance(@NonNull RCTCheckoutWebView view) { Log.d(TAG, "onDropViewInstance: Properly cleaning up CheckoutWebView"); diff --git a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java index 64ba94bb..a110c647 100644 --- a/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java +++ b/modules/@shopify/checkout-sheet-kit/android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java @@ -32,6 +32,7 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutsheetkit.CheckoutException; import com.shopify.checkoutsheetkit.CheckoutExpiredException; +import com.shopify.checkoutsheetkit.CheckoutPaymentMethodChangeStartParams; import com.shopify.checkoutsheetkit.CheckoutSheetKitException; import com.shopify.checkoutsheetkit.ClientException; import com.shopify.checkoutsheetkit.ConfigurationException; @@ -44,6 +45,8 @@ of this software and associated documentation files (the "Software"), to deal import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutStartEvent; import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStart; import com.shopify.checkoutsheetkit.rpc.events.CheckoutAddressChangeStartEvent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shopify.checkoutsheetkit.rpc.events.CheckoutPaymentMethodChangeStart; import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStart; import com.shopify.checkoutsheetkit.rpc.events.CheckoutSubmitStartEvent; import com.fasterxml.jackson.databind.ObjectMapper; @@ -175,6 +178,22 @@ public void onAddressChangeStart(@NonNull CheckoutAddressChangeStart event) { } @Override + public void onPaymentMethodChangeStart(@NonNull CheckoutPaymentMethodChangeStart event) { + try { + CheckoutPaymentMethodChangeStartParams params = event.getParams(); + + Map eventData = new HashMap<>(); + eventData.put("id", event.getId()); + eventData.put("type", "paymentMethodChangeStart"); + eventData.put("cart", params.getCart()); + + String data = mapper.writeValueAsString(eventData); + sendEventWithStringData("paymentMethodChangeStart", data); + } catch (IOException e) { + Log.e(TAG, "Error processing address change start event", e); + } + } + public void onSubmitStart(@NonNull CheckoutSubmitStart event) { try { CheckoutSubmitStartEvent params = event.getParams(); @@ -200,7 +219,6 @@ public void onSubmitStart(@NonNull CheckoutSubmitStart event) { } // Private - private Map populateErrorDetails(CheckoutException error) { Map errorMap = new HashMap<>(Map.of( "__typename", getErrorTypeName(error), diff --git a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift index 9bba636f..5df2eb19 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebView.swift @@ -1,25 +1,25 @@ /* -MIT License + MIT License -Copyright 2023 - Present, Shopify Inc. + Copyright 2023 - Present, Shopify Inc. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ import React import ShopifyCheckoutSheetKit @@ -27,307 +27,328 @@ import UIKit @objc(RCTCheckoutWebView) class RCTCheckoutWebView: UIView { - private var checkoutWebViewController: CheckoutWebViewController? + private var checkoutWebViewController: CheckoutWebViewController? - private struct EventBus { - typealias Event = any RPCRequest - private var events: [String: Event] = [:] + private struct EventBus { + typealias Event = any RPCRequest + private var events: [String: Event] = [:] - func get(key: String) -> Event? { - events[key] + func get(key: String) -> Event? { + events[key] + } + + mutating func set(key: String, event: Event) { + events[key] = event + } + + mutating func remove(key: String) { + events.removeValue(forKey: key) + } + + mutating func removeAll() { + events.removeAll() + } + } + + private var events: EventBus = .init() + private var pendingSetup = false + internal var setupScheduler: (@escaping () -> Void) -> Void = { work in + DispatchQueue.main.async(execute: work) + } + + struct CheckoutConfiguration: Equatable { + let url: String + let authToken: String? + } + + internal var lastConfiguration: CheckoutConfiguration? + + /// Public Properties + @objc var checkoutUrl: String? { + didSet { + guard checkoutUrl != oldValue else { return } + if checkoutUrl == nil { + removeCheckout() + } else { + scheduleSetupIfNeeded() + } + } } - mutating func set(key: String, event: Event) { - events[key] = event + @objc var auth: String? { + didSet { + guard auth != oldValue else { return } + scheduleSetupIfNeeded() + } } - mutating func remove(key: String) { - events.removeValue(forKey: key) + @objc var onStart: RCTBubblingEventBlock? + @objc var onError: RCTBubblingEventBlock? + @objc var onComplete: RCTBubblingEventBlock? + @objc var onCancel: RCTBubblingEventBlock? + @objc var onLinkClick: RCTBubblingEventBlock? + @objc var onAddressChangeStart: RCTBubblingEventBlock? + @objc var onPaymentMethodChangeStart: RCTBubblingEventBlock? + @objc var onSubmitStart: RCTBubblingEventBlock? + + override init(frame: CGRect) { + super.init(frame: frame) } - mutating func removeAll() { - events.removeAll() + required init?(coder: NSCoder) { + super.init(coder: coder) } - } - - private var events: EventBus = .init() - private var pendingSetup = false - internal var setupScheduler: (@escaping () -> Void) -> Void = { work in - DispatchQueue.main.async(execute: work) - } - struct CheckoutConfiguration: Equatable { - let url: String - let authToken: String? - } - internal var lastConfiguration: CheckoutConfiguration? - - /// Public Properties - @objc var checkoutUrl: String? { - didSet { - guard checkoutUrl != oldValue else { return } - if checkoutUrl == nil { + + deinit { + self.events.removeAll() removeCheckout() - } else { - scheduleSetupIfNeeded() - } } - } - @objc var auth: String? { - didSet { - guard auth != oldValue else { return } - scheduleSetupIfNeeded() + + func setup() { + pendingSetup = false + guard let urlString = checkoutUrl, + let url = URL(string: urlString) + else { + // Clear any existing checkout if URL is not available + removeCheckout() + return + } + + backgroundColor = UIColor.clear + + let newConfiguration = CheckoutConfiguration(url: urlString, authToken: auth) + guard newConfiguration != lastConfiguration else { + return + } + + _ = setupCheckoutWebViewController(with: url, configuration: newConfiguration) } - } - @objc var onStart: RCTBubblingEventBlock? - @objc var onError: RCTBubblingEventBlock? - @objc var onComplete: RCTBubblingEventBlock? - @objc var onCancel: RCTBubblingEventBlock? - @objc var onLinkClick: RCTBubblingEventBlock? - @objc var onAddressChangeStart: RCTBubblingEventBlock? - @objc var onPaymentChangeIntent: RCTBubblingEventBlock? - @objc var onSubmitStart: RCTBubblingEventBlock? - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - deinit { - self.events.removeAll() - removeCheckout() - } - - func setup() { - pendingSetup = false - guard let urlString = checkoutUrl, - let url = URL(string: urlString) else { - // Clear any existing checkout if URL is not available - removeCheckout() - return + + override func layoutSubviews() { + super.layoutSubviews() + setup() } - backgroundColor = UIColor.clear + @discardableResult + func setupCheckoutWebViewController(with url: URL, configuration: CheckoutConfiguration? = nil) -> Bool { + removeCheckout() + + guard let parentViewController else { + print("[CheckoutWebView] ERROR: Could not find parent view controller") + return false + } + + let options = auth.map { CheckoutOptions(authentication: .token($0)) } + let webViewController = CheckoutWebViewController(checkoutURL: url, delegate: self, options: options) + parentViewController.addChild(webViewController) + + if let view = webViewController.view { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: topAnchor), + view.leadingAnchor.constraint(equalTo: leadingAnchor), + view.trailingAnchor.constraint(equalTo: trailingAnchor), + view.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + webViewController.didMove(toParent: parentViewController) + checkoutWebViewController = webViewController + checkoutWebViewController?.view.frame = bounds + + webViewController.notifyPresented() + if let configuration { + lastConfiguration = configuration + } + return true + } - let newConfiguration = CheckoutConfiguration(url: urlString, authToken: auth) - guard newConfiguration != lastConfiguration else { - return + func removeCheckout() { + ShopifyCheckoutSheetKit.invalidate() + checkoutWebViewController?.willMove(toParent: nil) + checkoutWebViewController?.view.removeFromSuperview() + checkoutWebViewController?.removeFromParent() + checkoutWebViewController = nil + lastConfiguration = nil } - _ = setupCheckoutWebViewController(with: url, configuration: newConfiguration) - } + private func scheduleSetupIfNeeded() { + guard !pendingSetup else { return } + pendingSetup = true - override func layoutSubviews() { - super.layoutSubviews() - setup() - } + setupScheduler { [weak self] in + self?.setup() + } + } - @discardableResult - func setupCheckoutWebViewController(with url: URL, configuration: CheckoutConfiguration? = nil) -> Bool { - removeCheckout() + @objc func reload() { + guard let urlString = checkoutUrl, + let url = URL(string: urlString) + else { + return + } - guard let parentViewController else { - print("[CheckoutWebView] ERROR: Could not find parent view controller") - return false + let configuration = CheckoutConfiguration(url: urlString, authToken: auth) + _ = setupCheckoutWebViewController(with: url, configuration: configuration) } - let options = auth.map { CheckoutOptions(authentication: .token($0)) } - let webViewController = CheckoutWebViewController(checkoutURL: url, delegate: self, options: options) - parentViewController.addChild(webViewController) + @objc func respondToEvent(eventId id: String, responseData: String) { + print("[CheckoutWebView] Responding to event: \(id) with data: \(responseData)") - if let view = webViewController.view { - view.translatesAutoresizingMaskIntoConstraints = false - addSubview(view) + guard let event = events.get(key: id) else { + print("[CheckoutWebView] Event not found in registry: \(id)") + return + } - NSLayoutConstraint.activate([ - view.topAnchor.constraint(equalTo: topAnchor), - view.leadingAnchor.constraint(equalTo: leadingAnchor), - view.trailingAnchor.constraint(equalTo: trailingAnchor), - view.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) + handleEventResponse(for: event, with: responseData) } - webViewController.didMove(toParent: parentViewController) - checkoutWebViewController = webViewController - checkoutWebViewController?.view.frame = bounds + private func handleEventResponse( + for event: any RPCRequest, + with responseData: String + ) { + guard let id = event.id else { return } + + do { + try event.respondWith(json: responseData) + print("[CheckoutWebView] Successfully responded to event: \(id)") + events.remove(key: id) + } catch let error as CheckoutEventResponseError { + print("[CheckoutWebView] Event response error: \(error)") + handleEventError(eventId: id, error: error) + } catch { + print("[CheckoutWebView] Unexpected error responding to event: \(error)") + handleEventError(eventId: id, error: error) + } + } - webViewController.notifyPresented() - if let configuration { - lastConfiguration = configuration + private func handleEventError(eventId id: String, error: Error) { + let errorMessage: String + let errorCode: String + + if let eventError = error as? CheckoutEventResponseError { + switch eventError { + case .invalidEncoding: + errorMessage = "Invalid response data encoding" + errorCode = "ENCODING_ERROR" + case let .decodingFailed(details): + errorMessage = "Failed to decode address response: \(details)" + errorCode = "DECODING_ERROR" + case let .validationFailed(details): + errorMessage = "Invalid address data: \(details)" + errorCode = "VALIDATION_ERROR" + } + } else { + errorMessage = error.localizedDescription + errorCode = "UNKNOWN_ERROR" + } + + onError?([ + "error": errorMessage, + "eventId": id, + "code": errorCode + ]) + + events.remove(key: id) } - return true - } - - func removeCheckout() { - ShopifyCheckoutSheetKit.invalidate() - checkoutWebViewController?.willMove(toParent: nil) - checkoutWebViewController?.view.removeFromSuperview() - checkoutWebViewController?.removeFromParent() - checkoutWebViewController = nil - lastConfiguration = nil - } - - private func scheduleSetupIfNeeded() { - guard !pendingSetup else { return } - pendingSetup = true - - setupScheduler { [weak self] in - self?.setup() + + override func removeFromSuperview() { + removeCheckout() + super.removeFromSuperview() + events.removeAll() } - } +} - @objc func reload() { - guard let urlString = checkoutUrl, - let url = URL(string: urlString) else { - return +extension RCTCheckoutWebView: CheckoutDelegate { + func checkoutDidStart(event: CheckoutStartEvent) { + onStart?(ShopifyEventSerialization.serialize(checkoutStartEvent: event)) } - let configuration = CheckoutConfiguration(url: urlString, authToken: auth) - _ = setupCheckoutWebViewController(with: url, configuration: configuration) - } + func checkoutDidComplete(event: CheckoutCompleteEvent) { + onComplete?(ShopifyEventSerialization.serialize(checkoutCompleteEvent: event)) + } - @objc func respondToEvent(eventId id: String, responseData: String) { - print("[CheckoutWebView] Responding to event: \(id) with data: \(responseData)") + func checkoutDidCancel() { + onCancel?([:]) + } - guard let event = self.events.get(key: id) else { - print("[CheckoutWebView] Event not found in registry: \(id)") - return + func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { + onError?(ShopifyEventSerialization.serialize(checkoutError: error)) } - handleEventResponse(for: event, with: responseData) - } - - private func handleEventResponse( - for event: any RPCRequest, - with responseData: String - ) { - guard let id = event.id else { return } - - do { - try event.respondWith(json: responseData) - print("[CheckoutWebView] Successfully responded to event: \(id)") - self.events.remove(key: id) - } catch let error as CheckoutEventResponseError { - print("[CheckoutWebView] Event response error: \(error)") - handleEventError(eventId: id, error: error) - } catch { - print("[CheckoutWebView] Unexpected error responding to event: \(error)") - handleEventError(eventId: id, error: error) + func checkoutDidClickLink(url: URL) { + onLinkClick?(["url": url.absoluteString]) } - } - - private func handleEventError(eventId id: String, error: Error) { - let errorMessage: String - let errorCode: String - - if let eventError = error as? CheckoutEventResponseError { - switch eventError { - case .invalidEncoding: - errorMessage = "Invalid response data encoding" - errorCode = "ENCODING_ERROR" - case .decodingFailed(let details): - errorMessage = "Failed to decode address response: \(details)" - errorCode = "DECODING_ERROR" - case .validationFailed(let details): - errorMessage = "Invalid address data: \(details)" - errorCode = "VALIDATION_ERROR" - } - } else { - errorMessage = error.localizedDescription - errorCode = "UNKNOWN_ERROR" + + func shouldRecoverFromError(error: CheckoutError) -> Bool { + error.isRecoverable } - onError?([ - "error": errorMessage, - "eventId": id, - "code": errorCode, - ]) + /// Called when checkout starts an address change flow. + /// + /// This event is only emitted when native address selection is enabled + /// for the authenticated app. + /// + /// - Parameter event: The address change start event containing: + /// - id: Unique identifier for responding to the event + /// - addressType: Type of address being changed ("shipping" or "billing") + /// - cart: Current cart state + func checkoutDidStartAddressChange(event: CheckoutAddressChangeStart) { + guard let id = event.id else { return } + + events.set(key: id, event: event) + + let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) + + onAddressChangeStart?([ + "id": event.id, + "type": "addressChangeStart", + "addressType": event.params.addressType, + "cart": cartJSON + ]) + } - self.events.remove(key: id) - } + func checkoutDidStartPaymentMethodChange(event: CheckoutPaymentMethodChangeStart) { + guard let id = event.id else { return } - override func removeFromSuperview() { - removeCheckout() - super.removeFromSuperview() - self.events.removeAll() - } + events.set(key: id, event: event) -} + let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) -extension RCTCheckoutWebView: CheckoutDelegate { - func checkoutDidStart(event: CheckoutStartEvent) { - onStart?(ShopifyEventSerialization.serialize(checkoutStartEvent: event)) - } - - func checkoutDidComplete(event: CheckoutCompleteEvent) { - onComplete?(ShopifyEventSerialization.serialize(checkoutCompleteEvent: event)) - } - - func checkoutDidCancel() { - onCancel?([:]) - } - - func checkoutDidFail(error: ShopifyCheckoutSheetKit.CheckoutError) { - onError?(ShopifyEventSerialization.serialize(checkoutError: error)) - } - - func checkoutDidClickLink(url: URL) { - onLinkClick?(["url": url.absoluteString]) - } - - func shouldRecoverFromError(error: CheckoutError) -> Bool { - error.isRecoverable - } - - /// Called when checkout starts an address change flow. - /// - /// This event is only emitted when native address selection is enabled - /// for the authenticated app. - /// - /// - Parameter event: The address change start event containing: - /// - id: Unique identifier for responding to the event - /// - addressType: Type of address being changed ("shipping" or "billing") - /// - cart: Current cart state - func checkoutDidStartAddressChange(event: CheckoutAddressChangeStart) { - guard let id = event.id else { return } - - self.events.set(key: id, event: event) - - let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) - - onAddressChangeStart?([ - "id": event.id, - "type": "addressChangeStart", - "addressType": event.params.addressType, - "cart": cartJSON, - ]) - } - - /// Called when the buyer attempts to submit the checkout. - /// - /// This event is only emitted when native payment delegation is configured - /// for the authenticated app. - /// - /// - Parameter event: The submit start event containing: - /// - id: Unique identifier for responding to the event - /// - cart: Current cart state - /// - checkout: Checkout session information - func checkoutDidStartSubmit(event: CheckoutSubmitStart) { - guard let id = event.id else { return } - - self.events.set(key: id, event: event) - - let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) - - onSubmitStart?([ - "id": event.id, - "type": "submitStart", - "cart": cartJSON, - "checkout": [ - "id": event.params.checkout.id - ], - ]) - } + var eventData: [String: Any] = [ + "id": event.id, + "type": "paymentMethodChangeStart", + "cart": cartJSON + ] + + onPaymentMethodChangeStart?(eventData) + } + + /// Called when the buyer attempts to submit the checkout. + /// + /// This event is only emitted when native payment delegation is configured + /// for the authenticated app. + /// + /// - Parameter event: The submit start event containing: + /// - id: Unique identifier for responding to the event + /// - cart: Current cart state + /// - checkout: Checkout session information + func checkoutDidStartSubmit(event: CheckoutSubmitStart) { + guard let id = event.id else { return } + + events.set(key: id, event: event) + + let cartJSON = ShopifyEventSerialization.encodeToJSON(from: event.params.cart) + + onSubmitStart?([ + "id": event.id, + "type": "submitStart", + "cart": cartJSON, + "checkout": [ + "id": event.params.checkout.id + ] + ]) + } } diff --git a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebViewManager.swift b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebViewManager.swift index 42567155..fe910df2 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebViewManager.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/RCTCheckoutWebViewManager.swift @@ -1,33 +1,32 @@ /* -MIT License + MIT License -Copyright 2023 - Present, Shopify Inc. + Copyright 2023 - Present, Shopify Inc. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ -import UIKit import React import ShopifyCheckoutSheetKit +import UIKit @objc(RCTCheckoutWebViewManager) class RCTCheckoutWebViewManager: RCTViewManager { - override func view() -> UIView! { return RCTCheckoutWebView() } diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm index e6a80e75..8d42bea2 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm @@ -126,7 +126,7 @@ @interface RCT_EXTERN_MODULE (RCTCheckoutWebViewManager, RCTViewManager) /** * Emitted when checkout is moving to payment selection screen */ - RCT_EXPORT_VIEW_PROPERTY(onPaymentChangeIntent, RCTBubblingEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onPaymentMethodChangeStart, RCTBubblingEventBlock) /** * Emitted when the buyer attempts to submit the checkout diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index 5657dd06..de168613 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -322,13 +322,14 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { /// - Parameter options: Optional dictionary containing authentication /// - Returns: CheckoutOptions instance if options are provided, nil otherwise internal func parseCheckoutOptions(_ options: [AnyHashable: Any]?) -> CheckoutOptions? { - guard let options = options else { + guard let options else { return nil } // Parse authentication if let authDict = options["authentication"] as? [AnyHashable: Any], - let token = authDict["token"] as? String { + let token = authDict["token"] as? String + { return CheckoutOptions(authentication: .token(token)) } diff --git a/modules/@shopify/checkout-sheet-kit/ios/UIView+UIViewController.swift b/modules/@shopify/checkout-sheet-kit/ios/UIView+UIViewController.swift index f5cb6808..1a834155 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/UIView+UIViewController.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/UIView+UIViewController.swift @@ -22,15 +22,15 @@ */ extension UIView { - var parentViewController: UIViewController? { - // Starts from next (As we know self is not a UIViewController). - var parentResponder: UIResponder? = self.next - while parentResponder != nil { - if let viewController = parentResponder as? UIViewController { - return viewController - } - parentResponder = parentResponder?.next + var parentViewController: UIViewController? { + // Starts from next (As we know self is not a UIViewController). + var parentResponder: UIResponder? = next + while parentResponder != nil { + if let viewController = parentResponder as? UIViewController { + return viewController + } + parentResponder = parentResponder?.next + } + return nil } - return nil - } } diff --git a/modules/@shopify/checkout-sheet-kit/package.snapshot.json b/modules/@shopify/checkout-sheet-kit/package.snapshot.json index ad074165..bdb37feb 100644 --- a/modules/@shopify/checkout-sheet-kit/package.snapshot.json +++ b/modules/@shopify/checkout-sheet-kit/package.snapshot.json @@ -7,6 +7,7 @@ "android/src/main/AndroidManifest.xml", "android/src/main/AndroidManifestNew.xml", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEvent.java", + "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CheckoutEventType.java", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebView.java", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/RCTCheckoutWebViewManager.java", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/SheetCheckoutEventProcessor.java", diff --git a/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx b/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx index 6694fd7b..220024b3 100644 --- a/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/CheckoutEventProvider.tsx @@ -19,6 +19,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import React, {createContext, useContext, useRef, useCallback} from 'react'; import {UIManager, findNodeHandle} from 'react-native'; +import type { + CheckoutAddressChangeStart, + CheckoutAddressChangeStartResponse, + CheckoutPaymentMethodChangeStart, + CheckoutPaymentMethodChangeStartResponse, +} from './events'; interface CheckoutEventContextType { registerWebView: (webViewRef: React.RefObject) => void; @@ -103,17 +109,41 @@ export function useCheckoutEvents(): CheckoutEventContextType | null { return context; } +/** + * Register Event + Response schema pairs for respondWith typings + */ +type Pairs = + | [CheckoutAddressChangeStart, CheckoutAddressChangeStartResponse] + | [ + CheckoutPaymentMethodChangeStart, + CheckoutPaymentMethodChangeStartResponse, + ]; + +type MapEventToResponse = {[K in T[0]['type']]: T[1]}; +type EventNames = keyof MapEventToResponse; + +type Event = MapEventToResponse[T]; + +type RespondableEvent = { + id: string; + type: T; + respondWith: (response: Event) => Promise; +}; /** * Enhanced hook for working with specific Shopify checkout events * @param eventId The ID of the event to work with */ -export function useShopifyEvent(eventId: string) { +export function useShopifyEvent( + eventId: string, + type: T, +): RespondableEvent { const eventContext = useCheckoutEvents(); return { id: eventId, + type, respondWith: useCallback( - async (response: any) => { + async response => { if (!eventContext) { return false; } diff --git a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx index 38516c7e..123b37e4 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/Checkout.tsx @@ -28,18 +28,17 @@ import { requireNativeComponent, UIManager, findNodeHandle, + type ViewStyle, } from 'react-native'; -import type {ViewStyle} from 'react-native'; -import type { - CheckoutCompleteEvent, - CheckoutException, -} from '..'; import {useCheckoutEvents} from '../CheckoutEventProvider'; import type { CheckoutAddressChangeStart, + CheckoutCompleteEvent, + CheckoutPaymentMethodChangeStart, CheckoutStartEvent, - CheckoutSubmitStart + CheckoutSubmitStart, } from '../events'; +import type {CheckoutException} from '../errors'; export interface ShopifyCheckoutProps { /** @@ -93,6 +92,15 @@ export interface ShopifyCheckoutProps { */ onSubmitStart?: (event: CheckoutSubmitStart) => void; + /** + * Called when checkout starts a payment method change flow (e.g., for native picker). + * + * Note: This callback is only invoked when native address selection is enabled + * for the authenticated app. + */ + onPaymentMethodChangeStart?: ( + event: CheckoutPaymentMethodChangeStart, + ) => void; /** * Style for the webview container */ @@ -121,12 +129,19 @@ interface NativeShopifyCheckoutWebViewProps { onComplete?: (event: {nativeEvent: CheckoutCompleteEvent}) => void; onCancel?: () => void; onLinkClick?: (event: {nativeEvent: {url: string}}) => void; - onAddressChangeStart?: (event: {nativeEvent: CheckoutAddressChangeStart}) => void; + onAddressChangeStart?: (event: { + nativeEvent: CheckoutAddressChangeStart; + }) => void; onSubmitStart?: (event: {nativeEvent: CheckoutSubmitStart}) => void; + onPaymentMethodChangeStart?: (event: { + nativeEvent: CheckoutPaymentMethodChangeStart; + }) => void; } const RCTCheckoutWebView = - requireNativeComponent('RCTCheckoutWebView'); + requireNativeComponent( + 'RCTCheckoutWebView', + ); /** * Checkout provides a native webview component for displaying @@ -169,7 +184,10 @@ const RCTCheckoutWebView = * }} * /> */ -export const ShopifyCheckout = forwardRef( +export const ShopifyCheckout = forwardRef< + ShopifyCheckoutRef, + ShopifyCheckoutProps +>( ( { checkoutUrl, @@ -180,6 +198,7 @@ export const ShopifyCheckout = forwardRef eventContext.unregisterWebView(); }, [eventContext]); - const handleStart = useCallback< Required['onStart'] >( @@ -212,7 +230,7 @@ export const ShopifyCheckout = forwardRef['onError'] >( - (event: {nativeEvent: CheckoutException}) => { + event => { onError?.(event.nativeEvent); }, [onError], @@ -221,7 +239,7 @@ export const ShopifyCheckout = forwardRef['onComplete'] >( - (event: {nativeEvent: CheckoutCompleteEvent}) => { + event => { onComplete?.(event.nativeEvent); }, [onComplete], @@ -236,7 +254,7 @@ export const ShopifyCheckout = forwardRef['onLinkClick'] >( - (event: {nativeEvent: {url: string}}) => { + event => { if (!event.nativeEvent.url) return; onLinkClick?.(event.nativeEvent.url); }, @@ -246,17 +264,27 @@ export const ShopifyCheckout = forwardRef['onAddressChangeStart'] >( - (event: {nativeEvent: CheckoutAddressChangeStart}) => { + event => { if (!event.nativeEvent) return; onAddressChangeStart?.(event.nativeEvent); }, [onAddressChangeStart], ); + const handlePaymentMethodChangeStart = useCallback< + Required['onPaymentMethodChangeStart'] + >( + event => { + if (!event.nativeEvent) return; + onPaymentMethodChangeStart?.(event.nativeEvent); + }, + [onPaymentMethodChangeStart], + ); + const handleSubmitStart = useCallback< Required['onSubmitStart'] >( - (event: {nativeEvent: CheckoutSubmitStart}) => { + event => { if (!event.nativeEvent) return; onSubmitStart?.(event.nativeEvent); }, @@ -267,6 +295,7 @@ export const ShopifyCheckout = forwardRef ({reload}), [reload]); @@ -297,6 +322,7 @@ export const ShopifyCheckout = forwardRef ); diff --git a/modules/@shopify/checkout-sheet-kit/src/events.d.ts b/modules/@shopify/checkout-sheet-kit/src/events.d.ts index 06c29607..b8c2eca2 100644 --- a/modules/@shopify/checkout-sheet-kit/src/events.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/events.d.ts @@ -46,6 +46,8 @@ export interface Cart { discountAllocations: CartDiscountAllocation[]; /** Delivery addresses for the cart */ delivery: CartDelivery; + /** Payment information for the cart */ + payment: CartPayment; } /** @@ -262,6 +264,25 @@ export interface CartDeliveryAddress { */ export type CartAddress = CartDeliveryAddress; +/** + * Payment instrument available for selection at checkout. + * Output type from Storefront API. + * + * @see https://shopify.dev/docs/api/storefront/latest/objects/CartPaymentInstrument + */ +export interface CartPaymentInstrument { + externalReference: string; +} + +/** + * Payment information available for the cart. + * + * @see https://shopify.dev/docs/api/storefront/latest/objects/CartPayment + */ +export interface CartPayment { + instruments: CartPaymentInstrument[]; +} + /** * Discount applied to a cart line or the entire cart. * Shows how much was discounted and which code/promotion applied it. @@ -354,7 +375,6 @@ export interface CheckoutStartEvent { cart: Cart; } - /** * Error object returned in checkout event responses. * Used to communicate validation or processing errors back to checkout. @@ -472,6 +492,11 @@ export interface CartInput { * Optional - use to apply discount codes to the cart. */ discountCodes?: string[]; + /** + * Payment instruments for the cart. + * Optional - use to update payment methods. + */ + paymentInstruments?: CartPaymentInstrumentInput[]; } /** @@ -529,21 +554,84 @@ export interface CheckoutAddressChangeStartResponse { errors?: CheckoutResponseError[]; } +/** + * Card brand identifiers for payment instruments. + * Matches Storefront API card brand values. + */ +export type CardBrand = + | 'VISA' + | 'MASTERCARD' + | 'AMERICAN_EXPRESS' + | 'DISCOVER' + | 'DINERS_CLUB' + | 'JCB' + | 'MAESTRO' + | 'UNKNOWN'; + +/** + * Expiry date for a payment instrument. + */ +export interface ExpiryInput { + /** Month (1-12) */ + month: number; + /** Four-digit year */ + year: number; +} + +/** + * Display fields for a payment instrument shown to the buyer. + */ +export interface CartPaymentInstrumentDisplayInput { + /** Last 4 digits of the card number */ + last4: string; + /** Card brand (e.g., VISA, MASTERCARD) */ + brand: CardBrand; + /** Name of the cardholder */ + cardHolderName: string; + /** Card expiry date */ + expiry: ExpiryInput; +} + +/** + * Input type for creating/updating payment instruments, aligned with Storefront API. + * Display fields are grouped separately from the billing address. + * + * @see https://shopify.dev/docs/api/storefront/latest/input-objects/CartPaymentInstrumentInput + */ +export interface CartPaymentInstrumentInput { + /** Unique identifier for this payment instrument */ + externalReference: string; + /** Display information for the payment instrument */ + display: CartPaymentInstrumentDisplayInput; + /** Billing address for the payment instrument */ + billingAddress: MailingAddressInput; +} + /** * Event emitted when the buyer intends to change their payment method. */ -export interface CheckoutPaymentChangeIntent { +export interface CheckoutPaymentMethodChangeStart { /** Unique identifier for this event instance */ id: string; /** Type of payment change event */ - type: string; - /** Current payment card information, if available */ - currentCard?: { - /** Last 4 digits of the card number */ - last4: string; - /** Card brand (e.g., "Visa", "Mastercard") */ - brand: string; - }; + type: 'paymentMethodChangeStart'; + /** Cart state when the event was emitted */ + cart: Cart; +} + +/** + * Response payload for CheckoutPaymentMethodChangeStart event. + * Use with CheckoutEventProvider.respondToEvent() or useShopifyEvent().respondWith() + */ +export interface CheckoutPaymentMethodChangeStartResponse { + /** + * Updated cart input with the payment instruments to set. + */ + cart?: CartInput; + /** + * Optional array of errors if the payment method selection failed. + */ + errors?: CheckoutResponseError[]; } /** diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 76bdd8cf..758eb3b2 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -505,13 +505,20 @@ export type { CartDeliveryAddressInput, CartDeliveryInput, CartInput, + CardBrand, + CartPayment, + CartPaymentInstrument, + CartPaymentInstrumentDisplayInput, + CartPaymentInstrumentInput, PaymentTokenInput, CheckoutSession, CheckoutAddressChangeStart, CheckoutAddressChangeStartResponse, CheckoutCompleteEvent, - CheckoutPaymentChangeIntent, + CheckoutPaymentMethodChangeStart, + CheckoutPaymentMethodChangeStartResponse, CheckoutResponseError, + ExpiryInput, CheckoutStartEvent, CheckoutSubmitStart, CheckoutSubmitStartResponse, diff --git a/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts b/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts index a2174a72..f55d62f0 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/testFixtures.ts @@ -69,6 +69,9 @@ export function createTestCart(overrides?: Partial): Cart { }, ], }, + payment: { + instruments: [], + }, ...overrides, }; } diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index 965f222f..93a8bfae 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -2891,6 +2891,6 @@ SPEC CHECKSUMS: SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a742cc68e8366fcfc681808162492bc0aa7a9498 -PODFILE CHECKSUM: c18c67add5bb02cbc43b46941cc12b33c4a7572f +PODFILE CHECKSUM: e096ca5df616e23383b3aad99d7484917ddb6df1 COCOAPODS: 1.15.2 diff --git a/sample/src/screens/BuyNow/AddressScreen.tsx b/sample/src/screens/BuyNow/AddressScreen.tsx index 3ecc346a..c2fc69ee 100644 --- a/sample/src/screens/BuyNow/AddressScreen.tsx +++ b/sample/src/screens/BuyNow/AddressScreen.tsx @@ -20,16 +20,22 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import type {RouteProp} from '@react-navigation/native'; import {useNavigation, useRoute} from '@react-navigation/native'; import React, {useState} from 'react'; -import {Alert, Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import { + Alert, + Button, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; import {useShopifyEvent} from '@shopify/checkout-sheet-kit'; -import type {CheckoutAddressChangeStartResponse} from '@shopify/checkout-sheet-kit'; import {useCart} from '../../context/Cart'; import type {BuyNowStackParamList} from './types'; export default function AddressScreen() { const route = useRoute>(); const navigation = useNavigation(); - const event = useShopifyEvent(route.params.id); + const event = useShopifyEvent(route.params.id, 'addressChangeStart'); const {selectedAddressIndex, setSelectedAddressIndex} = useCart(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -84,22 +90,18 @@ export default function AddressScreen() { setIsSubmitting(true); try { - const selectedAddress = addressOptions[selectedAddressIndex]; - - const response: CheckoutAddressChangeStartResponse = { + await event.respondWith({ cart: { delivery: { addresses: [ { - address: selectedAddress!.address, + address: addressOptions[selectedAddressIndex]!.address, selected: true, }, ], }, }, - }; - - await event.respondWith(response); + }); await new Promise(resolve => setTimeout(resolve, 500)); diff --git a/sample/src/screens/BuyNow/CheckoutScreen.tsx b/sample/src/screens/BuyNow/CheckoutScreen.tsx index 17f97cd7..e113ada8 100644 --- a/sample/src/screens/BuyNow/CheckoutScreen.tsx +++ b/sample/src/screens/BuyNow/CheckoutScreen.tsx @@ -24,6 +24,7 @@ import { ShopifyCheckout, type CheckoutAddressChangeStart, type CheckoutCompleteEvent, + type CheckoutPaymentMethodChangeStart, type CheckoutRef, type CheckoutStartEvent, type CheckoutSubmitStart, @@ -50,6 +51,11 @@ export default function CheckoutScreen(props: { navigation.navigate('Address', {id: event.id}); }; + const onPaymentMethodChangeStart = (event: CheckoutPaymentMethodChangeStart) => { + console.log(' onPaymentMethodChangeStart: ', event); + navigation.navigate('Payment', {id: event.id}); + } + const onSubmitStart = async (event: CheckoutSubmitStart) => { console.log(' onSubmitStart', event); try { @@ -88,6 +94,7 @@ export default function CheckoutScreen(props: { style={styles.container} onStart={onStart} onAddressChangeStart={onAddressChangeStart} + onPaymentMethodChangeStart={onPaymentMethodChangeStart} onSubmitStart={onSubmitStart} onCancel={onCancel} onError={onError} diff --git a/sample/src/screens/BuyNow/PaymentScreen.tsx b/sample/src/screens/BuyNow/PaymentScreen.tsx index feec05e0..e3c93736 100644 --- a/sample/src/screens/BuyNow/PaymentScreen.tsx +++ b/sample/src/screens/BuyNow/PaymentScreen.tsx @@ -21,78 +21,110 @@ import type {RouteProp} from '@react-navigation/native'; import {useNavigation, useRoute} from '@react-navigation/native'; import React from 'react'; import {Button, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; -import {useShopifyEvent} from '@shopify/checkout-sheet-kit'; +import { + useShopifyEvent, + type CardBrand, + type MailingAddressInput, +} from '@shopify/checkout-sheet-kit'; import {useCart} from '../../context/Cart'; import type {BuyNowStackParamList} from './types'; export default function PaymentScreen() { const route = useRoute>(); const navigation = useNavigation(); - const event = useShopifyEvent(route.params.id); + const event = useShopifyEvent(route.params.id, 'addressChangeStart'); const {selectedPaymentIndex, setSelectedPaymentIndex} = useCart(); - const paymentOptions = [ + const paymentOptions: Array<{ + id: string; + label: string; + cardHolderName: string; + last4: string; + brand: CardBrand; + expiry: {month: number; year: number}; + billingAddress: MailingAddressInput; + }> = [ { + id: 'card-personal-visa-4242', label: 'Personal Visa', - card: { - last4: '4242', - brand: 'Visa', - }, - billing: { - useDeliveryAddress: true, + cardHolderName: 'John Doe', + last4: '4242', + brand: 'VISA', + expiry: {month: 12, year: 2028}, + billingAddress: { + firstName: 'John', + lastName: 'Doe', + address1: '123 Main St', + city: 'San Francisco', + provinceCode: 'CA', + countryCode: 'US', + zip: '94102', }, }, { + id: 'card-business-mc-5555', label: 'Business MasterCard', - card: { - last4: '5555', - brand: 'Mastercard', - }, - billing: { - useDeliveryAddress: true, + cardHolderName: 'Jane Smith', + last4: '5555', + brand: 'MASTERCARD', + expiry: {month: 6, year: 2027}, + billingAddress: { + firstName: 'Jane', + lastName: 'Smith', + address1: '456 Market St', + city: 'San Francisco', + provinceCode: 'CA', + countryCode: 'US', + zip: '94103', }, }, { + id: 'card-corporate-amex-0005', label: 'Corporate Amex', - card: { - last4: '0005', - brand: 'American Express', - }, - billing: { - useDeliveryAddress: false, - address: { - firstName: 'Corporate', - lastName: 'Billing', - address1: '123 Business Blvd', - address2: 'Suite 500', - city: 'New York', - provinceCode: 'NY', - countryCode: 'US', - zip: '10001', - phone: '+1-212-555-0100', - company: 'Acme Corporation', - }, + cardHolderName: 'Corporate Account', + last4: '0005', + brand: 'AMERICAN_EXPRESS', + expiry: {month: 3, year: 2026}, + billingAddress: { + firstName: 'Corporate', + lastName: 'Billing', + address1: '123 Business Blvd', + address2: 'Suite 500', + city: 'New York', + provinceCode: 'NY', + countryCode: 'US', + zip: '10001', + phone: '+1-212-555-0100', + company: 'Acme Corporation', }, }, ]; - const handlePaymentSelection = () => { + const handlePaymentSelection = async () => { const selectedPayment = paymentOptions[selectedPaymentIndex]; - event.respondWith({ - card: selectedPayment!.card, - billing: selectedPayment!.billing, + if (!selectedPayment) return; + + await event.respondWith({ + cart: { + paymentInstruments: [ + { + externalReference: selectedPayment.id, + display: { + last4: selectedPayment.last4, + brand: selectedPayment.brand, + cardHolderName: selectedPayment.cardHolderName, + expiry: selectedPayment.expiry, + }, + billingAddress: selectedPayment.billingAddress, + }, + ], + }, }); navigation.goBack(); }; - const getCardIcon = (brand: string) => { - // In a real app, you'd use actual card brand icons - const brandIcons: {[key: string]: string} = { - 'Visa': '💳', - 'Mastercard': '💳', - 'American Express': '💳', - }; - return brandIcons[brand] || '💳'; + const getCardIcon = (_brand: CardBrand) => { + return '💳'; }; return ( @@ -116,16 +148,14 @@ export default function PaymentScreen() { - {getCardIcon(option.card.brand)} + {getCardIcon(option.brand)} {option.label} - {option.card.brand} •••• {option.card.last4} + {option.brand} •••• {option.last4} - {option.billing.useDeliveryAddress - ? 'Uses delivery address' - : `Separate billing: ${option.billing.address?.city}, ${option.billing.address?.provinceCode}`} + {option.billingAddress.city}, {option.billingAddress.provinceCode}