From f8461e74375a4dc683d5a23a9b95e545f4ef8ca4 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 6 Jul 2026 14:08:27 +0200 Subject: [PATCH] Add Swift checkout branding configuration --- .../ShopifyCheckoutKit/CheckoutProtocol.swift | 2 +- .../CheckoutURLDecorator.swift | 43 ++++++++++++++ .../CheckoutViewController.swift | 8 ++- .../ShopifyCheckoutKit.swift | 6 +- .../CheckoutURLDecoratorTests.swift | 57 +++++++++++++++++++ .../CheckoutWebViewControllerTests.swift | 4 +- .../CheckoutWebViewTests.swift | 10 ++-- .../SwiftUITests.swift | 20 +++++++ 8 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 platforms/swift/Sources/ShopifyCheckoutKit/CheckoutURLDecorator.swift create mode 100644 platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift index 39ef73a91..b62f5caf8 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutProtocol.swift @@ -7,7 +7,7 @@ public enum CheckoutProtocol { public typealias Client = EmbeddedCheckoutProtocol.Client public static func url(for url: URL) -> URL { - EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: defaultDelegations)) + CheckoutURLDecorator.decorate(url) } static let defaultDelegations: [EmbeddedCheckoutProtocol.Delegation] = [.windowOpen] diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutURLDecorator.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutURLDecorator.swift new file mode 100644 index 000000000..948acc600 --- /dev/null +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutURLDecorator.swift @@ -0,0 +1,43 @@ +#if !COCOAPODS + import EmbeddedCheckoutProtocol +#endif +import Foundation + +enum CheckoutURLDecorator { + static func decorate( + _ url: URL, + configuration: Configuration = ShopifyCheckoutKit.configuration + ) -> URL { + let decorated = EmbeddedCheckoutProtocol.url( + for: url, + options: .init( + delegations: CheckoutProtocol.defaultDelegations, + colorScheme: configuration.colorScheme.rawValue + ) + ) + + guard var components = URLComponents(url: decorated, resolvingAgainstBaseURL: false) else { + return decorated + } + + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == Self.brandingQueryItemName } + queryItems.append(URLQueryItem(name: Self.brandingQueryItemName, value: configuration.colorScheme.brandingValue)) + components.queryItems = queryItems + + return components.url ?? decorated + } + + private static let brandingQueryItemName = "ck_branding" +} + +extension Configuration.ColorScheme { + fileprivate var brandingValue: String { + switch self { + case .web: + return "shop" + case .automatic, .dark, .light: + return "app" + } + } +} diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift index 69cf30e4e..7bd4465ec 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/CheckoutViewController.swift @@ -33,11 +33,15 @@ public struct ShopifyCheckout: UIViewControllerRepresentable, CheckoutConfigurab var onFailAction: ((CheckoutError) -> Void)? public init(checkout url: URL) { - checkoutURL = CheckoutProtocol.url(for: url) + checkoutURL = url + } + + var decoratedCheckoutURL: URL { + CheckoutURLDecorator.decorate(checkoutURL) } public func makeUIViewController(context _: Self.Context) -> CheckoutViewController { - let viewController = CheckoutViewController(checkout: checkoutURL, client: client) + let viewController = CheckoutViewController(checkout: decoratedCheckoutURL, client: client) configureWebViewController(viewController) return viewController } diff --git a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift index 798864504..0cd822a3b 100644 --- a/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift +++ b/platforms/swift/Sources/ShopifyCheckoutKit/ShopifyCheckoutKit.swift @@ -48,7 +48,7 @@ public func preload(checkout url: URL) { return } - let decorated = CheckoutProtocol.url(for: url) + let decorated = CheckoutURLDecorator.decorate(url) CheckoutWebView.preload(checkout: decorated) } @@ -61,7 +61,7 @@ public func invalidate() { @MainActor @discardableResult public func present(checkout url: URL, from: UIViewController, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { - let decorated = CheckoutProtocol.url(for: url) + let decorated = CheckoutURLDecorator.decorate(url) let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client) from.present(viewController, animated: true) return viewController @@ -70,7 +70,7 @@ public func present(checkout url: URL, from: UIViewController, delegate: (any Ch @MainActor @discardableResult package func present(checkout url: URL, from: UIViewController, entryPoint: MetaData.EntryPoint, delegate: (any CheckoutDelegate)? = nil, client: (any CheckoutCommunicationProtocol)? = nil) -> CheckoutViewController { - let decorated = CheckoutProtocol.url(for: url) + let decorated = CheckoutURLDecorator.decorate(url) let viewController = CheckoutViewController(checkout: decorated, delegate: delegate, client: client, entryPoint: entryPoint) from.present(viewController, animated: true) return viewController diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift new file mode 100644 index 000000000..5d987b6ab --- /dev/null +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutURLDecoratorTests.swift @@ -0,0 +1,57 @@ +import Foundation +@testable import ShopifyCheckoutKit +import Testing + +@Suite("Checkout URL Decoration") +struct CheckoutURLDecoratorTests { + @Test func appendsColorSchemeAndDerivedAppBranding() throws { + var configuration = Configuration() + configuration.colorScheme = .dark + + let url = try #require(URL(string: "https://shop.com/cart/c/abc?key=cart_token")) + let items = queryItems(CheckoutURLDecorator.decorate(url, configuration: configuration)) + + #expect(items.first(where: { $0.name == "key" })?.value == "cart_token") + #expect(items.first(where: { $0.name == "ec_color_scheme" })?.value == "dark") + #expect(items.first(where: { $0.name == "ck_branding" })?.value == "app") + } + + @Test func replacesCallerSuppliedBrandingAndIsIdempotent() throws { + var configuration = Configuration() + configuration.colorScheme = .light + + let url = try #require(URL(string: "https://shop.com/cart/c/abc?ck_branding=app&ec_color_scheme=dark")) + let once = CheckoutURLDecorator.decorate(url, configuration: configuration) + let twice = CheckoutURLDecorator.decorate(once, configuration: configuration) + let items = queryItems(twice) + + #expect(items.filter { $0.name == "ck_branding" }.map(\.value) == ["app"]) + #expect(items.filter { $0.name == "ec_color_scheme" }.map(\.value) == ["light"]) + } + + @Test func derivesBrandingForEachColorScheme() throws { + try assertColorSchemeDecoratesWith(.light, colorScheme: "light", branding: "app") + try assertColorSchemeDecoratesWith(.dark, colorScheme: "dark", branding: "app") + try assertColorSchemeDecoratesWith(.automatic, colorScheme: "automatic", branding: "app") + try assertColorSchemeDecoratesWith(.web, colorScheme: "web_default", branding: "shop") + } + + private func assertColorSchemeDecoratesWith( + _ colorScheme: Configuration.ColorScheme, + colorScheme expectedColorScheme: String, + branding expectedBranding: String + ) throws { + var configuration = Configuration() + configuration.colorScheme = colorScheme + + let url = try #require(URL(string: "https://shop.com/cart/c/abc")) + let items = queryItems(CheckoutURLDecorator.decorate(url, configuration: configuration)) + + #expect(items.first(where: { $0.name == "ec_color_scheme" })?.value == expectedColorScheme) + #expect(items.first(where: { $0.name == "ck_branding" })?.value == expectedBranding) + } + + private func queryItems(_ url: URL) -> [URLQueryItem] { + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + } +} diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift index 427cd5618..1c47211a2 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewControllerTests.swift @@ -75,7 +75,7 @@ class CheckoutWebViewControllerTests: XCTestCase { func test_presentationControllerDidDismiss_doesNotCleanUpBeforeViewDisappears() throws { ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.preload(checkout: url) - let viewController = TestableCheckoutWebViewController(checkoutURL: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations)), entryPoint: nil) + let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutURLDecorator.decorate(url), entryPoint: nil) viewController.loadViewIfNeeded() let checkoutView = try XCTUnwrap(viewController.checkoutView) @@ -93,7 +93,7 @@ class CheckoutWebViewControllerTests: XCTestCase { func test_viewDidDisappear_cleansUpConsumedPreloadedWebViewWhenDismissed() throws { ShopifyCheckoutKit.configuration.preloading.enabled = true ShopifyCheckoutKit.preload(checkout: url) - let viewController = TestableCheckoutWebViewController(checkoutURL: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations)), entryPoint: nil) + let viewController = TestableCheckoutWebViewController(checkoutURL: CheckoutURLDecorator.decorate(url), entryPoint: nil) viewController.loadViewIfNeeded() let checkoutView = try XCTUnwrap(viewController.checkoutView) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift index 34318b4ef..140904ce9 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/CheckoutWebViewTests.swift @@ -284,7 +284,7 @@ class CheckoutWebViewTests: XCTestCase { XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertTrue(CheckoutWebView.preloadCache.hasActiveKeepAlive()) - let cached = CheckoutWebView.for(checkout: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations))) + let cached = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) XCTAssertFalse(CheckoutWebView.preloadCache.hasActiveKeepAlive()) @@ -305,8 +305,8 @@ class CheckoutWebViewTests: XCTestCase { func testPresentingMatchingCheckoutReusesCachedWebViewWithoutEvictingIt() { ShopifyCheckoutKit.preload(checkout: url) - let first = CheckoutWebView.for(checkout: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations))) - let second = CheckoutWebView.for(checkout: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations))) + let first = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) + let second = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) XCTAssertTrue(first === second) XCTAssertTrue(CheckoutWebView.preloadCache.hasEntry()) @@ -411,7 +411,7 @@ class CheckoutWebViewTests: XCTestCase { func testInvalidateDetachesCachedPreloadedWebView() { ShopifyCheckoutKit.preload(checkout: url) - let cached = CheckoutWebView.for(checkout: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations))) + let cached = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) XCTAssertTrue(cached.isBridgeAttached) ShopifyCheckoutKit.invalidate() @@ -422,7 +422,7 @@ class CheckoutWebViewTests: XCTestCase { func testHTTPErrorInvalidatesPreloadCache() throws { ShopifyCheckoutKit.preload(checkout: url) - let cached = CheckoutWebView.for(checkout: EmbeddedCheckoutProtocol.url(for: url, options: .init(delegations: CheckoutProtocol.defaultDelegations))) + let cached = CheckoutWebView.for(checkout: CheckoutURLDecorator.decorate(url)) let link = try XCTUnwrap(cached.url) let response = try XCTUnwrap(HTTPURLResponse(url: link, statusCode: 403, httpVersion: nil, headerFields: nil)) diff --git a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift index e8894b2ea..d80177506 100644 --- a/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift +++ b/platforms/swift/Tests/ShopifyCheckoutKitTests/SwiftUITests.swift @@ -24,10 +24,16 @@ class ShopifyCheckoutTests: XCTestCase { override func setUp() async throws { try await super.setUp() + ShopifyCheckoutKit.configuration = Configuration() checkoutURL = URL(string: "https://www.shopify.com") shopifyCheckout = ShopifyCheckout(checkout: checkoutURL) } + override func tearDown() async throws { + ShopifyCheckoutKit.configuration = Configuration() + try await super.tearDown() + } + func testOnCancel() { var cancelActionCalled = false @@ -67,10 +73,16 @@ class CheckoutConfigurableTests: XCTestCase { override func setUp() async throws { try await super.setUp() + ShopifyCheckoutKit.configuration = Configuration() checkoutURL = URL(string: "https://www.shopify.com") shopifyCheckout = ShopifyCheckout(checkout: checkoutURL) } + override func tearDown() async throws { + ShopifyCheckoutKit.configuration = Configuration() + try await super.tearDown() + } + func testBackgroundColor() { let color = UIColor.red shopifyCheckout.backgroundColor(color) @@ -83,6 +95,14 @@ class CheckoutConfigurableTests: XCTestCase { XCTAssertEqual(ShopifyCheckoutKit.configuration.colorScheme, colorScheme) } + func testColorSchemeDecoratesCheckoutURLAfterModifierRuns() throws { + let sheet = shopifyCheckout.colorScheme(.web) + let items = try XCTUnwrap(URLComponents(url: sheet.decoratedCheckoutURL, resolvingAgainstBaseURL: false)?.queryItems) + + XCTAssertEqual(items.first(where: { $0.name == "ec_color_scheme" })?.value, "web_default") + XCTAssertEqual(items.first(where: { $0.name == "ck_branding" })?.value, "shop") + } + func testTintColor() { let color = UIColor.blue shopifyCheckout.tintColor(color)