diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift index 8e0c588e..5c97cab6 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartViewController.swift @@ -148,8 +148,12 @@ class CartViewController: UIViewController, UITableViewDelegate, UITableViewData } extension CartViewController: CheckoutDelegate { - func checkoutDidComplete() { + func checkoutDidComplete(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent) { resetCart() + + if let orderId = event.orderId { + print("Order created:", orderId) + } } func checkoutDidCancel() { diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift index 933a19b2..e8fb059e 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutBridge.swift @@ -81,7 +81,7 @@ enum CheckoutBridge { extension CheckoutBridge { enum WebEvent: Decodable { - case checkoutComplete + case checkoutComplete(event: CheckoutCompletedEvent) case checkoutExpired case checkoutUnavailable case checkoutModalToggled(modalVisible: Bool) @@ -100,7 +100,9 @@ extension CheckoutBridge { switch name { case "completed": - self = .checkoutComplete + let checkoutCompletedEventDecoder = CheckoutCompletedEventDecoder() + let checkoutCompletedEvent = try checkoutCompletedEventDecoder.decode(from: container, using: decoder) + self = .checkoutComplete(event: checkoutCompletedEvent) case "error": // needs to support .checkoutUnavailable by parsing error payload on body self = .checkoutExpired diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEventDecoder.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEventDecoder.swift new file mode 100644 index 00000000..9e411025 --- /dev/null +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutCompletedEventDecoder.swift @@ -0,0 +1,44 @@ +/* +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. +*/ + +import Foundation + +public struct CheckoutCompletedEvent: Decodable { + public let orderId: String? + + enum CodingKeys: String, CodingKey { + case orderId + } +} + +class CheckoutCompletedEventDecoder { + func decode(from container: KeyedDecodingContainer, using decoder: Decoder) throws -> CheckoutCompletedEvent { + let messageBody = try container.decode(String.self, forKey: .body) + + guard let data = messageBody.data(using: .utf8) else { + return CheckoutCompletedEvent(orderId: nil) + } + + return try JSONDecoder().decode(CheckoutCompletedEvent.self, from: data) + } +} diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift index 187633c7..94414d3b 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutDelegate.swift @@ -27,7 +27,7 @@ import UIKit /// A delegate protocol for managing checkout lifecycle events. public protocol CheckoutDelegate: AnyObject { /// Tells the delegate that the checkout successfully completed. - func checkoutDidComplete() + func checkoutDidComplete(event: CheckoutCompletedEvent) /// Tells the delegate that the checkout was cancelled by the buyer. func checkoutDidCancel() diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index ecf60013..ee575585 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -26,7 +26,7 @@ import WebKit protocol CheckoutWebViewDelegate: AnyObject { func checkoutViewDidStartNavigation() - func checkoutViewDidCompleteCheckout() + func checkoutViewDidCompleteCheckout(event: CheckoutCompletedEvent) func checkoutViewDidFinishNavigation() func checkoutViewDidClickLink(url: URL) func checkoutViewDidFailWithError(error: CheckoutError) @@ -142,9 +142,9 @@ extension CheckoutWebView: WKScriptMessageHandler { func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { do { switch try CheckoutBridge.decode(message) { - case .checkoutComplete: + case let .checkoutComplete(checkoutCompletedEvent): CheckoutWebView.cache = nil - viewDelegate?.checkoutViewDidCompleteCheckout() + viewDelegate?.checkoutViewDidCompleteCheckout(event: checkoutCompletedEvent) case .checkoutUnavailable: CheckoutWebView.cache = nil viewDelegate?.checkoutViewDidFailWithError(error: .checkoutUnavailable(message: "Checkout unavailable.")) diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift index 204dbe9b..aca857bb 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebViewController.swift @@ -144,10 +144,10 @@ extension CheckoutWebViewController: CheckoutWebViewDelegate { } } - func checkoutViewDidCompleteCheckout() { + func checkoutViewDidCompleteCheckout(event: CheckoutCompletedEvent) { ConfettiCannon.fire(in: view) CheckoutWebView.invalidate() - delegate?.checkoutDidComplete() + delegate?.checkoutDidComplete(event: event) } func checkoutViewDidFailWithError(error: CheckoutError) { diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift index c122a261..3e9fc566 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutBridgeTests.swift @@ -190,6 +190,29 @@ class CheckoutBridgeTests: XCTestCase { XCTAssertEqual("2024-01-04T09:48:53.358Z", pageViewedEvent.timestamp) } + func testDecodeSupportsCheckoutCompletedEvent() throws { + let orderId = "gid://shopify/OrderIdentity/8" + let body = "{\"orderId\": \"\(orderId)\"}" + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "") + + let mock = WKScriptMessageMock(body: """ + { + "name": "completed", + "body": "\(body)" + } + """) + + let result = try CheckoutBridge.decode(mock) + + guard case .checkoutComplete(let event) = result else { + XCTFail("Expected .checkoutComplete, got \(result)") + return + } + + XCTAssertEqual(orderId, event.orderId) + } + func testDecoderThrowsBridgeErrorWhenMandatoryAttributesAreMissing() throws { let body = "{\"name\": \"page_viewed\",\"event\": {\"name\": \"page_viewed\",\"type\":\"standard\",\"timestamp\": \"2024-01-04T09:48:53.358Z\", \"context\": {}}}" .replacingOccurrences(of: "\"", with: "\\\"") diff --git a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift index bde767f8..3405fc81 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/CheckoutViewControllerTests.swift @@ -50,7 +50,7 @@ class CheckoutViewDelegateTests: XCTestCase { let two = CheckoutWebView.for(checkout: checkoutURL) XCTAssertEqual(one, two) - viewController.checkoutViewDidCompleteCheckout() + viewController.checkoutViewDidCompleteCheckout(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent(orderId: nil)) let three = CheckoutWebView.for(checkout: checkoutURL) XCTAssertNotEqual(two, three) diff --git a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift index ff1ab4ab..68dd7c67 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutDelegate.swift @@ -25,7 +25,7 @@ import XCTest @testable import ShopifyCheckoutSheetKit class ExampleDelegate: CheckoutDelegate { - func checkoutDidComplete() { + func checkoutDidComplete(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent) { } func checkoutDidCancel() { diff --git a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift index 4e65b9a9..d6b1192a 100644 --- a/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift +++ b/Tests/ShopifyCheckoutSheetKitTests/Mocks/MockCheckoutWebViewDelegate.swift @@ -41,6 +41,8 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { var didEmitWebPixelsEventExpectation: XCTestExpectation? + var didEmitCheckoutCompletedEventExpectation: XCTestExpectation? + func checkoutViewDidStartNavigation() { didStartNavigationExpectation?.fulfill() } @@ -72,4 +74,8 @@ class MockCheckoutWebViewDelegate: CheckoutWebViewDelegate { func checkoutViewDidEmitWebPixelEvent(event: PixelEvent) { didEmitWebPixelsEventExpectation?.fulfill() } + + func checkoutViewDidCompleteCheckout(event: ShopifyCheckoutSheetKit.CheckoutCompletedEvent) { + didEmitCheckoutCompletedEventExpectation?.fulfill() + } }