diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj index e0188c06b..ee0d6c05a 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 2147F3E42B502AA9005546F3 /* ShopifyCheckoutSheetKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2147F3E32B502AA9005546F3 /* ShopifyCheckoutSheetKit */; }; 2147F3E62B502AFD005546F3 /* checkout-sheet-kit-swift in Resources */ = {isa = PBXBuildFile; fileRef = 2147F3E52B502AFD005546F3 /* checkout-sheet-kit-swift */; }; + 273247322E1BEC9000A9B14E /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 273247312E1BEC9000A9B14E /* ChatView.swift */; }; 4EA7F9B62A9D2B9D003276A1 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA7F9B42A9D2B9D003276A1 /* SettingsView.swift */; }; 4EBBA76B2A5F0CE200193E19 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */; }; 4EBBA76D2A5F0CE200193E19 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBBA76C2A5F0CE200193E19 /* SceneDelegate.swift */; }; @@ -42,6 +43,7 @@ /* Begin PBXFileReference section */ 2147F3E52B502AFD005546F3 /* checkout-sheet-kit-swift */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "checkout-sheet-kit-swift"; path = ../..; sourceTree = ""; }; + 273247312E1BEC9000A9B14E /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; 4EA7F9B42A9D2B9D003276A1 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 4EBBA7672A5F0CE200193E19 /* MobileBuyIntegration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MobileBuyIntegration.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4EBBA76A2A5F0CE200193E19 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -195,6 +197,7 @@ CB05E6C72D4B81B200466376 /* Views */ = { isa = PBXGroup; children = ( + 273247312E1BEC9000A9B14E /* ChatView.swift */, 6A2E77BF2CE6186F0067062D /* CartView.swift */, 6A2E77BD2CE606400067062D /* ProductGridView.swift */, 6A3467322B600E64007314A8 /* LogsView.swift */, @@ -354,6 +357,7 @@ CB05E6BD2D493B8F00466376 /* ApplePayHandler.swift in Sources */, 4EBBA76D2A5F0CE200193E19 /* SceneDelegate.swift in Sources */, CB05E6C12D4954A600466376 /* PaymentCodable.swift in Sources */, + 273247322E1BEC9000A9B14E /* ChatView.swift in Sources */, 6A2E77C02CE618720067062D /* CartView.swift in Sources */, 6A3393652CEF9E8100E89FAA /* Theme.swift in Sources */, 6A774DD12B58023400C8EF7E /* CountryCode+inferRegion.swift in Sources */, diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift index 8b2df7796..18361a477 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/AppDelegate.swift @@ -29,7 +29,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, willFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { ShopifyCheckoutSheetKit.configure { /// Checkout color scheme setting - $0.colorScheme = .web + $0.colorScheme = .automatic /// Customize progress bar color $0.tintColor = ColorPalette.primaryColor diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift index a4474ce73..c59dac303 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/CartManager.swift @@ -80,7 +80,7 @@ class CartManager: ObservableObject { ) { $0.cartLinesAdd(cartId: cartId, lines: lines) { $0.cart { $0.cartManagerFragment() } - .userErrors { $0.code().message() } + .userErrors { $0.code().message() } } } diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings index 4599c22d8..13b60b348 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Localizable.xcstrings @@ -9,6 +9,9 @@ }, "✓" : { + }, + "🛍️ Complete Your Purchase" : { + }, "Add to Cart" : { @@ -24,9 +27,18 @@ }, "By default, the app will only handle the selections above and route everything else to Safari. Enabling the \"Handle all Universal Links\" setting will route all Universal Links to this app." : { + }, + "Chat Support" : { + }, "Checkout" : { + }, + "Checkout Complete" : { + + }, + "Checkout Error" : { + }, "Checkout Sheet Kit version" : { @@ -54,6 +66,9 @@ }, "Handle Product URLs" : { + }, + "Loading checkout..." : { + }, "Loading products..." : { @@ -108,6 +123,9 @@ }, "Theme" : { + }, + "Type a message..." : { + }, "Universal Links" : { @@ -117,6 +135,9 @@ }, "Web pixel events" : { + }, + "You can complete your checkout right here:" : { + }, "Your cart is empty." : { diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift index b4504a160..1778edc5f 100644 --- a/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift @@ -30,6 +30,7 @@ enum Screen: Int, CaseIterable { case catalog case products case cart + case chat case settings } @@ -41,7 +42,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let cartController = UIHostingController(rootView: CartView()) let productGridController = UIHostingController(rootView: ProductGridView()) let productGalleryController = UIHostingController(rootView: ProductGalleryView()) - let settingsController = UIHostingController(rootView: SettingsView()) + let chatController = UIHostingController(rootView: ChatView()) + let settingsController = UIHostingController(rootView: AnyView(SettingsView().environmentObject(CartManager.shared))) func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { guard let windowScene = (scene as? UIWindowScene) else { return } @@ -63,6 +65,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { /// Cart screen viewControllers[Screen.cart.rawValue] = UINavigationController(rootViewController: cartController) + /// Chat screen + viewControllers[Screen.chat.rawValue] = UINavigationController(rootViewController: chatController) + /// Settings screen viewControllers[Screen.settings.rawValue] = UINavigationController(rootViewController: settingsController) @@ -108,6 +113,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { cartController.tabBarItem.title = "Cart" cartController.navigationItem.title = "Cart" + /// Chat + chatController.tabBarItem.image = UIImage(systemName: "message.circle") + chatController.tabBarItem.title = "Chat" + chatController.navigationItem.title = "Chat Support" + /// Settings settingsController.tabBarItem.image = UIImage(systemName: "gearshape.2") settingsController.tabBarItem.title = "Settings" diff --git a/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ChatView.swift b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ChatView.swift new file mode 100644 index 000000000..22afe3558 --- /dev/null +++ b/Samples/MobileBuyIntegration/MobileBuyIntegration/Views/ChatView.swift @@ -0,0 +1,298 @@ +/* + 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 ShopifyCheckoutSheetKit +import SwiftUI + +struct ChatView: View { + @ObservedObject var cartManager: CartManager = .shared + @State private var messages: [ChatMessage] = [ + ChatMessage(text: "Hi! I can help you complete your purchase. Would you like to checkout now?", isUser: false) + ] + @State private var showingInlineCheckout = false + @State private var messageText = "" + @State private var checkoutCompleted = false + @State private var checkoutError: String? = nil + @State private var isCheckoutLoading = true + + var body: some View { + VStack(spacing: 0) { + // Chat messages + ScrollView { + LazyVStack(spacing: 12) { + ForEach(messages) { message in + ChatMessageRow(message: message) + } + + // Inline checkout view + if showingInlineCheckout, let checkoutUrl = cartManager.cart?.checkoutUrl { + CheckoutMessageView( + checkoutUrl: checkoutUrl, + isLoading: $isCheckoutLoading + ) { result in + switch result { + case .completed: + checkoutCompleted = true + showingInlineCheckout = false + messages.append(ChatMessage(text: "✅ Checkout completed successfully!", isUser: false)) + case .cancelled: + showingInlineCheckout = false + messages.append(ChatMessage(text: "Checkout was cancelled. Let me know if you'd like to try again.", isUser: false)) + case let .failed(error): + checkoutError = error.localizedDescription + showingInlineCheckout = false + messages.append(ChatMessage(text: "❌ Checkout failed: \(error.localizedDescription)", isUser: false)) + } + } + } + } + .padding() + } + + // Quick actions + if !showingInlineCheckout { + QuickActionsView { action in + switch action { + case .checkout: + isCheckoutLoading = true + showingInlineCheckout = true + case .help: + messages.append(ChatMessage(text: "I need help with my order", isUser: true)) + messages.append(ChatMessage(text: "I'm here to help! What would you like to know?", isUser: false)) + case .support: + messages.append(ChatMessage(text: "Contact support", isUser: true)) + messages.append(ChatMessage(text: "You can reach our support team at support@example.com", isUser: false)) + } + } + } + + // Message input + MessageInputView(text: $messageText) { + if !messageText.isEmpty { + messages.append(ChatMessage(text: messageText, isUser: true)) + messageText = "" + + // Simple bot response + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + messages.append(ChatMessage(text: "Thanks for your message! How can I help you today?", isUser: false)) + } + } + } + } + .navigationTitle("Chat Support") + .navigationBarTitleDisplayMode(.inline) + .alert("Checkout Complete", isPresented: $checkoutCompleted) { + Button("OK") {} + } + .alert("Checkout Error", isPresented: Binding( + get: { checkoutError != nil }, + set: { _ in checkoutError = nil } + )) { + Button("OK") {} + } message: { + Text(checkoutError ?? "") + } + } +} + +struct ChatMessage: Identifiable { + let id = UUID() + let text: String + let isUser: Bool +} + +struct ChatMessageRow: View { + let message: ChatMessage + + var body: some View { + HStack { + if message.isUser { + Spacer() + Text(message.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.blue) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxWidth: 250, alignment: .trailing) + } else { + Text(message.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .foregroundColor(.primary) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxWidth: 250, alignment: .leading) + Spacer() + } + } + } +} + +struct CheckoutMessageView: View { + let checkoutUrl: URL + @Binding var isLoading: Bool + let onResult: (CheckoutResult) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("🛍️ Complete Your Purchase") + .font(.headline) + .padding(.horizontal) + + Text("You can complete your checkout right here:") + .font(.subheadline) + .foregroundColor(.secondary) + .padding(.horizontal) + + ZStack { + InlineCheckout( + checkout: checkoutUrl, + autoResizeHeight: true, + onCheckoutComplete: { _ in + onResult(.completed) + }, + onCheckoutCancel: { + onResult(.cancelled) + }, + onCheckoutFail: { error in + onResult(.failed(error)) + }, + onHeightChange: { _ in + // Height changes are handled by the loading state + }, + onLoadingStateChange: { loading in + withAnimation(.easeInOut(duration: 0.3)) { + isLoading = loading + } + } + ) + .frame(maxWidth: .infinity) + .opacity(isLoading ? 0 : 1) + + if isLoading { + VStack(spacing: 12) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .accentColor)) + .scaleEffect(1.0) + + Text("Loading checkout...") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: 80) + .transition(.opacity) + } + } + .frame(height: isLoading ? 80 : nil) + .frame(minHeight: 80) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(.systemGray4), lineWidth: 1) + ) + .padding(.horizontal) + } + .padding(.vertical, 8) + .background(Color(.systemGray6).opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +enum QuickAction { + case checkout + case help + case support +} + +enum CheckoutResult { + case completed + case cancelled + case failed(Error) +} + +struct QuickActionsView: View { + let onAction: (QuickAction) -> Void + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + QuickActionButton(title: "🛒 Checkout", action: .checkout, onAction: onAction) + QuickActionButton(title: "❓ Help", action: .help, onAction: onAction) + QuickActionButton(title: "📞 Support", action: .support, onAction: onAction) + } + .padding(.horizontal) + } + .padding(.vertical, 8) + .background(Color(.systemGray6)) + } +} + +struct QuickActionButton: View { + let title: String + let action: QuickAction + let onAction: (QuickAction) -> Void + + var body: some View { + Button(action: { + onAction(action) + }) { + Text(title) + .font(.system(size: 14, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.blue) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } +} + +struct MessageInputView: View { + @Binding var text: String + let onSend: () -> Void + + var body: some View { + HStack(spacing: 12) { + TextField("Type a message...", text: $text) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { + onSend() + } + + Button(action: onSend) { + Image(systemName: "paperplane.fill") + .foregroundColor(.blue) + } + .disabled(text.isEmpty) + } + .padding() + .background(Color(.systemGray6)) + } +} + +#Preview { + ChatView() + .environmentObject(CartManager.shared) +} diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift index 44b17d4b0..748c9a1fc 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutViewController.swift @@ -60,6 +60,311 @@ extension CheckoutViewController { } } +internal class InlineCheckoutWebViewDelegate: CheckoutWebViewDelegate { + weak var wrapper: InlineCheckoutWebViewWrapper? + weak var checkoutDelegate: CheckoutDelegate? + + init(wrapper: InlineCheckoutWebViewWrapper, checkoutDelegate: CheckoutDelegate?) { + self.wrapper = wrapper + self.checkoutDelegate = checkoutDelegate + } + + func checkoutViewDidStartNavigation() {} + + func checkoutViewDidCompleteCheckout(event: CheckoutCompletedEvent) { + checkoutDelegate?.checkoutDidComplete(event: event) + } + + func checkoutViewDidFinishNavigation() {} + + func checkoutViewDidClickLink(url: URL) { + checkoutDelegate?.checkoutDidClickLink(url: url) + } + + func checkoutViewDidFailWithError(error: CheckoutError) { + checkoutDelegate?.checkoutDidFail(error: error) + } + + func checkoutViewDidToggleModal(modalVisible: Bool) {} + + func checkoutViewDidEmitWebPixelEvent(event: PixelEvent) { + checkoutDelegate?.checkoutDidEmitWebPixelEvent(event: event) + } + +} + +public class InlineCheckoutWebViewWrapper: UIView { + internal var webView: CheckoutWebView! + private var contentHeight: CGFloat = 400 + private var checkoutURL: URL? + public var delegate: CheckoutDelegate? + private var autoResizeHeight: Bool = true + private var webViewHeightConstraint: NSLayoutConstraint? + private var inlineDelegate: InlineCheckoutWebViewDelegate? + var onHeightChangeWrapper: ((CGFloat) -> Void)? + var onLoadingStateChange: ((Bool) -> Void)? + private var isLoading = true + private var hasReceivedFirstHeightChange = false + + override public init(frame: CGRect) { + super.init(frame: frame) + setupWebView() + setupNotificationObserver() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupWebView() + setupNotificationObserver() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func setupWebView() { + // WebView will be set via configure method + } + + private func setupNotificationObserver() { + NotificationCenter.default.addObserver( + self, + selector: #selector(configurationChanged), + name: Notification.Name("colorSchemeChanged"), + object: nil + ) + } + + @objc private func configurationChanged() { + // Recreate webview with new configuration + if checkoutURL != nil { + recreateWebView() + } + } + + func configure(with checkoutURL: URL, delegate: CheckoutDelegate?, autoResizeHeight: Bool) { + // Store parameters for potential recreation + self.checkoutURL = checkoutURL + self.delegate = delegate + self.autoResizeHeight = autoResizeHeight + + createWebView() + } + + private func createWebView() { + guard let checkoutURL = checkoutURL else { + OSLogger.shared.debug("No checkout URL provided") + return + } + + OSLogger.shared.debug("Creating webview for URL: \(checkoutURL.absoluteString), autoResizeHeight: \(autoResizeHeight)") + + webView = CheckoutWebView.for(checkout: checkoutURL) + webView.autoResizeHeight = autoResizeHeight + webView.onHeightChange = { [weak self] height in + OSLogger.shared.debug("Height change callback received: \(height)") + self?.updateHeight(height) + } + + inlineDelegate = InlineCheckoutWebViewDelegate(wrapper: self, checkoutDelegate: delegate) + webView.viewDelegate = inlineDelegate + + webView.load(checkout: checkoutURL) + + addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + if autoResizeHeight { + // Create a height constraint that we can update when content height changes + webViewHeightConstraint = webView.heightAnchor.constraint(equalToConstant: contentHeight) + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + webViewHeightConstraint! + ]) + + // Disable scrolling so content sizes naturally + webView.scrollView.isScrollEnabled = false + webView.scrollView.bounces = false + + // Set frame AFTER constraints to avoid content adapting to container + webView.frame = CGRect(x: 0, y: 0, width: bounds.width > 0 ? bounds.width : 375, height: contentHeight) + + OSLogger.shared.debug("Auto-resize mode enabled, initial height: \(contentHeight), frame: \(webView.frame)") + } else { + // Standard full-height constraint for non-auto-resize + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: topAnchor), + webView.leadingAnchor.constraint(equalTo: leadingAnchor), + webView.trailingAnchor.constraint(equalTo: trailingAnchor), + webView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + // Set frame for non-auto-resize mode + webView.frame = CGRect(x: 0, y: 0, width: bounds.width > 0 ? bounds.width : 375, height: 400) + + OSLogger.shared.debug("Standard mode with bottom constraint") + } + + // Force initial layout + setNeedsLayout() + layoutIfNeeded() + } + + private func recreateWebView() { + // Remove existing webview + webView?.removeFromSuperview() + + // Invalidate cache to pick up new configuration + CheckoutWebView.invalidate() + + // Create new webview with updated configuration + createWebView() + + // Update layout + invalidateIntrinsicContentSize() + } + + + internal func updateHeight(_ height: CGFloat) { + guard height != contentHeight else { return } + + OSLogger.shared.debug("Updating wrapper height from \(contentHeight) to \(height)") + let previousHeight = contentHeight + contentHeight = height + + // Check if this is the first meaningful height change (indicates content is loaded) + // Use a higher threshold to ensure content is actually loaded + if !hasReceivedFirstHeightChange && height > 300 { + hasReceivedFirstHeightChange = true + isLoading = false + onLoadingStateChange?(false) + } + + // Update the webview height constraint to match the measured content height + if autoResizeHeight, let heightConstraint = webViewHeightConstraint { + heightConstraint.constant = height + OSLogger.shared.debug("Updated webview height constraint to \(height)") + } + + // Animate the height change with smooth animation + // Use different timing based on height change magnitude for better UX + let heightDiff = abs(height - previousHeight) + let animationDuration: TimeInterval = min(1.2, max(0.8, heightDiff / 400.0)) + + // First update the intrinsic content size to let SwiftUI know about the change + invalidateIntrinsicContentSize() + + // Then animate the layout changes + UIView.animate( + withDuration: animationDuration, + delay: 0, + usingSpringWithDamping: 0.85, + initialSpringVelocity: 0.3, + options: [.curveEaseInOut, .allowUserInteraction], + animations: { + // Force layout updates on the view hierarchy + self.setNeedsLayout() + self.layoutIfNeeded() + + // Update parent views to propagate the animation + var currentView: UIView? = self.superview + while currentView != nil { + currentView?.setNeedsLayout() + currentView?.layoutIfNeeded() + currentView = currentView?.superview + } + }, + completion: { _ in + // Notify SwiftUI after the animation completes + self.onHeightChangeWrapper?(height) + } + ) + } + + override public var intrinsicContentSize: CGSize { + if webView?.autoResizeHeight == true { + return CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + return CGSize(width: UIView.noIntrinsicMetric, height: 400) + } +} + +public struct InlineCheckout: UIViewRepresentable, CheckoutConfigurable { + public typealias UIViewType = InlineCheckoutWebViewWrapper + + public var checkoutURL: URL + public var autoResizeHeight: Bool = true + + // SwiftUI-friendly event handlers + public var onCheckoutComplete: ((CheckoutCompletedEvent) -> Void)? + public var onCheckoutCancel: (() -> Void)? + public var onCheckoutFail: ((CheckoutError) -> Void)? + public var onHeightChange: ((CGFloat) -> Void)? + public var onPixelEvent: ((PixelEvent) -> Void)? + public var onLinkClick: ((URL) -> Void)? + public var onLoadingStateChange: ((Bool) -> Void)? + + public init( + checkout url: URL, + autoResizeHeight: Bool = true, + onCheckoutComplete: ((CheckoutCompletedEvent) -> Void)? = nil, + onCheckoutCancel: (() -> Void)? = nil, + onCheckoutFail: ((CheckoutError) -> Void)? = nil, + onHeightChange: ((CGFloat) -> Void)? = nil, + onPixelEvent: ((PixelEvent) -> Void)? = nil, + onLinkClick: ((URL) -> Void)? = nil, + onLoadingStateChange: ((Bool) -> Void)? = nil + ) { + self.checkoutURL = url + self.autoResizeHeight = autoResizeHeight + self.onCheckoutComplete = onCheckoutComplete + self.onCheckoutCancel = onCheckoutCancel + self.onCheckoutFail = onCheckoutFail + self.onHeightChange = onHeightChange + self.onPixelEvent = onPixelEvent + self.onLinkClick = onLinkClick + self.onLoadingStateChange = onLoadingStateChange + + /// We handle configuration changes manually via notifications + /// to ensure proper cache invalidation timing + ShopifyCheckoutSheetKit.invalidateOnConfigurationChange = false + } + + public func makeUIView(context: Self.Context) -> InlineCheckoutWebViewWrapper { + let wrapper = InlineCheckoutWebViewWrapper() + + // Create a delegate wrapper that uses our closure properties + let delegate = InlineCheckoutDelegateWrapper() + delegate.onComplete = onCheckoutComplete + delegate.onCancel = onCheckoutCancel + delegate.onFail = onCheckoutFail + delegate.onPixelEvent = onPixelEvent + delegate.onLinkClick = onLinkClick + + wrapper.configure(with: checkoutURL, delegate: delegate, autoResizeHeight: autoResizeHeight) + wrapper.onHeightChangeWrapper = onHeightChange + wrapper.onLoadingStateChange = onLoadingStateChange + + return wrapper + } + + public func updateUIView(_ uiView: InlineCheckoutWebViewWrapper, context: Self.Context) { + // Update delegate with current closure properties + if let delegate = uiView.delegate as? InlineCheckoutDelegateWrapper { + delegate.onComplete = onCheckoutComplete + delegate.onCancel = onCheckoutCancel + delegate.onFail = onCheckoutFail + delegate.onPixelEvent = onPixelEvent + delegate.onLinkClick = onLinkClick + } + uiView.onHeightChangeWrapper = onHeightChange + uiView.onLoadingStateChange = onLoadingStateChange + } +} + public struct CheckoutSheet: UIViewControllerRepresentable, CheckoutConfigurable { public typealias UIViewControllerType = CheckoutViewController @@ -158,6 +463,42 @@ public class CheckoutDelegateWrapper: CheckoutDelegate { } } +public class InlineCheckoutDelegateWrapper: CheckoutDelegate { + var onComplete: ((CheckoutCompletedEvent) -> Void)? + var onCancel: (() -> Void)? + var onFail: ((CheckoutError) -> Void)? + var onPixelEvent: ((PixelEvent) -> Void)? + var onLinkClick: ((URL) -> Void)? + + public func checkoutDidFail(error: CheckoutError) { + onFail?(error) + } + + public func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + onPixelEvent?(event) + } + + public func checkoutDidComplete(event: CheckoutCompletedEvent) { + onComplete?(event) + } + + public func checkoutDidCancel() { + onCancel?() + } + + public func checkoutDidClickLink(url: URL) { + if let onLinkClick = onLinkClick { + onLinkClick(url) + return + } + + /// Use fallback behavior if callback is not provided + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } +} + public protocol CheckoutConfigurable { func backgroundColor(_ color: UIColor) -> Self func colorScheme(_ colorScheme: ShopifyCheckoutSheetKit.Configuration.ColorScheme) -> Self diff --git a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift index 7e606608d..b9cdfc454 100644 --- a/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift +++ b/Sources/ShopifyCheckoutSheetKit/CheckoutWebView.swift @@ -37,7 +37,7 @@ protocol CheckoutWebViewDelegate: AnyObject { private let deprecatedReasonHeader = "x-shopify-api-deprecated-reason" private let checkoutLiquidNotSupportedReason = "checkout_liquid_not_supported" -class CheckoutWebView: WKWebView { +public class CheckoutWebView: WKWebView { private static var cache: CacheEntry? internal var timer: Date? @@ -125,6 +125,8 @@ class CheckoutWebView: WKWebView { } } var isPreloadRequest: Bool = false + var autoResizeHeight: Bool = false + var onHeightChange: ((CGFloat) -> Void)? // MARK: Initializers init(frame: CGRect = .zero, configuration: WKWebViewConfiguration = WKWebViewConfiguration(), recovery: Bool = false) { @@ -244,7 +246,7 @@ class CheckoutWebView: WKWebView { } extension CheckoutWebView: WKScriptMessageHandler { - func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { + public func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { do { switch try CheckoutBridge.decode(message) { /// Completed event @@ -292,7 +294,7 @@ extension CheckoutWebView: WKScriptMessageHandler { } extension CheckoutWebView: WKNavigationDelegate { - func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + public func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = action.request.url else { decisionHandler(.allow) return @@ -308,7 +310,7 @@ extension CheckoutWebView: WKNavigationDelegate { decisionHandler(.allow) } - func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + public func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { if let response = navigationResponse.response as? HTTPURLResponse { decisionHandler(handleResponse(response)) return @@ -375,7 +377,7 @@ extension CheckoutWebView: WKNavigationDelegate { return .allow } - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { let url = webView.url?.absoluteString ?? "" OSLogger.shared.info("Started provisional navigation - url:\(url)") timer = Date() @@ -383,13 +385,13 @@ extension CheckoutWebView: WKNavigationDelegate { } /// No need to emit checkoutDidFail error here as it has been handled in handleResponse already - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { let url = webView.url?.absoluteString ?? "" OSLogger.shared.debug("Failed provisional navigation with error: \(error.localizedDescription) url:\(url)") timer = nil } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { viewDelegate?.checkoutViewDidFinishNavigation() if let startTime = timer { @@ -411,9 +413,15 @@ extension CheckoutWebView: WKNavigationDelegate { } checkoutDidLoad = true timer = nil + + if autoResizeHeight { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.startAppHeightMonitoring() + } + } } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { timer = nil let nsError = error as NSError @@ -437,9 +445,9 @@ extension CheckoutWebView: WKNavigationDelegate { } guard let url = action.request.url else { return false } - guard let url = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + guard let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - guard let openExternally = url.queryItems?.first(where: { $0.name == "open_externally" })?.value else { return false } + guard let openExternally = urlComponents.queryItems?.first(where: { $0.name == "open_externally" })?.value else { return false } return openExternally.lowercased() == "true" || openExternally == "1" } @@ -469,8 +477,36 @@ extension CheckoutWebView: WKNavigationDelegate { return nil } + + private func startAppHeightMonitoring() { + // Create a timer that checks app element height every 500ms + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.checkAppElementHeight() + } + } + + private func checkAppElementHeight() { + let script = """ + (function() { + const appElement = document.getElementById('app'); + if (appElement) { + return Math.max(appElement.offsetHeight, appElement.scrollHeight, appElement.clientHeight); + } + return 0; + })(); + """ + + evaluateJavaScript(script) { [weak self] result, error in + guard let self = self, let height = result as? CGFloat, height > 0 else { return } + + DispatchQueue.main.async { + self.onHeightChange?(height) + } + } + } } + extension CheckoutWebView { fileprivate struct CacheEntry { let key: String diff --git a/Tests/ShopifyCheckoutSheetKitTests/InlineCheckoutTests.swift b/Tests/ShopifyCheckoutSheetKitTests/InlineCheckoutTests.swift new file mode 100644 index 000000000..cfa176437 --- /dev/null +++ b/Tests/ShopifyCheckoutSheetKitTests/InlineCheckoutTests.swift @@ -0,0 +1,320 @@ +/* +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 XCTest +@testable import ShopifyCheckoutSheetKit + +// MARK: - InlineCheckout SwiftUI Tests + +class InlineCheckoutTests: XCTestCase { + private var checkoutURL: URL! + private var inlineCheckout: InlineCheckout! + + override func setUp() { + super.setUp() + checkoutURL = URL(string: "https://www.example.com/checkout")! + inlineCheckout = InlineCheckout(checkout: checkoutURL) + } + + override func tearDown() { + inlineCheckout = nil + checkoutURL = nil + super.tearDown() + } + + func testInitialization() { + XCTAssertEqual(inlineCheckout.checkoutURL, checkoutURL) + XCTAssertTrue(inlineCheckout.autoResizeHeight) + } + + func testInitializationWithCustomSettings() { + let customInlineCheckout = InlineCheckout( + checkout: checkoutURL, + autoResizeHeight: false + ) + + XCTAssertEqual(customInlineCheckout.checkoutURL, checkoutURL) + XCTAssertFalse(customInlineCheckout.autoResizeHeight) + } + + func testOnCheckoutComplete() { + var completeCalled = false + var receivedEvent: CheckoutCompletedEvent? + let event = createEmptyCheckoutCompletedEvent() + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onCheckoutComplete: { event in + completeCalled = true + receivedEvent = event + } + ) + + inlineCheckoutWithHandlers.onCheckoutComplete?(event) + + XCTAssertTrue(completeCalled) + XCTAssertNotNil(receivedEvent) + } + + func testOnCheckoutCancel() { + var cancelCalled = false + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onCheckoutCancel: { + cancelCalled = true + } + ) + + inlineCheckoutWithHandlers.onCheckoutCancel?() + + XCTAssertTrue(cancelCalled) + } + + func testOnCheckoutFail() { + var failCalled = false + var receivedError: CheckoutError? + let error: CheckoutError = .checkoutUnavailable(message: "Test error", code: CheckoutUnavailable.httpError(statusCode: 500), recoverable: false) + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onCheckoutFail: { error in + failCalled = true + receivedError = error + } + ) + + inlineCheckoutWithHandlers.onCheckoutFail?(error) + + XCTAssertTrue(failCalled) + XCTAssertNotNil(receivedError) + } + + func testOnHeightChange() { + var heightChangeCalled = false + var receivedHeight: CGFloat = 0 + let testHeight: CGFloat = 650 + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onHeightChange: { height in + heightChangeCalled = true + receivedHeight = height + } + ) + + inlineCheckoutWithHandlers.onHeightChange?(testHeight) + + XCTAssertTrue(heightChangeCalled) + XCTAssertEqual(receivedHeight, testHeight) + } + + func testOnPixelEvent() { + var pixelEventCalled = false + var receivedEvent: PixelEvent? + let standardEvent = StandardEvent(context: nil, id: "testId", name: "checkout_started", timestamp: "2022-01-01T00:00:00Z", data: nil) + let pixelEvent = PixelEvent.standardEvent(standardEvent) + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onPixelEvent: { event in + pixelEventCalled = true + receivedEvent = event + } + ) + + inlineCheckoutWithHandlers.onPixelEvent?(pixelEvent) + + XCTAssertTrue(pixelEventCalled) + XCTAssertNotNil(receivedEvent) + } + + func testOnLinkClick() { + var linkClickCalled = false + var receivedURL: URL? + let testURL = URL(string: "https://shopify.com")! + + let inlineCheckoutWithHandlers = InlineCheckout( + checkout: checkoutURL, + onLinkClick: { url in + linkClickCalled = true + receivedURL = url + } + ) + + inlineCheckoutWithHandlers.onLinkClick?(testURL) + + XCTAssertTrue(linkClickCalled) + XCTAssertEqual(receivedURL, testURL) + } +} + +// MARK: - InlineCheckoutWebViewWrapper Tests + +class InlineCheckoutWebViewWrapperTests: XCTestCase { + private var wrapper: InlineCheckoutWebViewWrapper! + private var mockDelegate: MockInlineCheckoutDelegate! + private var checkoutURL: URL! + + override func setUp() { + super.setUp() + checkoutURL = URL(string: "https://www.example.com/checkout")! + mockDelegate = MockInlineCheckoutDelegate() + wrapper = InlineCheckoutWebViewWrapper() + } + + override func tearDown() { + wrapper = nil + mockDelegate = nil + checkoutURL = nil + super.tearDown() + } + + func testWrapperInitialization() { + XCTAssertNotNil(wrapper) + XCTAssertEqual(wrapper.intrinsicContentSize.height, 400) + } + + func testHeightUpdating() { + wrapper.configure(with: checkoutURL, delegate: mockDelegate, autoResizeHeight: true) + + var heightChangeCallbackReceived = false + var receivedHeight: CGFloat = 0 + + wrapper.onHeightChangeWrapper = { height in + heightChangeCallbackReceived = true + receivedHeight = height + } + + let newHeight: CGFloat = 600 + wrapper.updateHeight(newHeight) + + XCTAssertTrue(heightChangeCallbackReceived) + XCTAssertEqual(receivedHeight, newHeight) + XCTAssertEqual(wrapper.intrinsicContentSize.height, newHeight) + } + + func testHeightUpdatingWithSameValue() { + wrapper.configure(with: checkoutURL, delegate: mockDelegate, autoResizeHeight: true) + + var heightChangeCallbackCount = 0 + wrapper.onHeightChangeWrapper = { _ in + heightChangeCallbackCount += 1 + } + + wrapper.updateHeight(400) + XCTAssertEqual(heightChangeCallbackCount, 0) + + wrapper.updateHeight(500) + XCTAssertEqual(heightChangeCallbackCount, 1) + } +} + +// MARK: - InlineCheckoutDelegateWrapper Tests + +class InlineCheckoutDelegateWrapperTests: XCTestCase { + private var delegateWrapper: InlineCheckoutDelegateWrapper! + + override func setUp() { + super.setUp() + delegateWrapper = InlineCheckoutDelegateWrapper() + } + + override func tearDown() { + delegateWrapper = nil + super.tearDown() + } + + func testCheckoutDidComplete() { + var completeCalled = false + var receivedEvent: CheckoutCompletedEvent? + let event = createEmptyCheckoutCompletedEvent() + + delegateWrapper.onComplete = { event in + completeCalled = true + receivedEvent = event + } + + delegateWrapper.checkoutDidComplete(event: event) + + XCTAssertTrue(completeCalled) + XCTAssertNotNil(receivedEvent) + } + + func testCheckoutDidCancel() { + var cancelCalled = false + + delegateWrapper.onCancel = { + cancelCalled = true + } + + delegateWrapper.checkoutDidCancel() + + XCTAssertTrue(cancelCalled) + } + + func testCheckoutDidFail() { + var failCalled = false + var receivedError: CheckoutError? + let error: CheckoutError = .checkoutUnavailable(message: "Test error", code: CheckoutUnavailable.httpError(statusCode: 500), recoverable: false) + + delegateWrapper.onFail = { error in + failCalled = true + receivedError = error + } + + delegateWrapper.checkoutDidFail(error: error) + + XCTAssertTrue(failCalled) + XCTAssertNotNil(receivedError) + } +} + +// MARK: - Mock Classes + +class MockInlineCheckoutDelegate: CheckoutDelegate { + var completedEventReceived: CheckoutCompletedEvent? + var errorReceived: CheckoutError? + var linkURLReceived: URL? + var pixelEventReceived: PixelEvent? + + func checkoutDidComplete(event: CheckoutCompletedEvent) { + completedEventReceived = event + } + + func checkoutDidCancel() { + // Mock implementation + } + + func checkoutDidFail(error: CheckoutError) { + errorReceived = error + } + + func checkoutDidClickContactLink(url: URL) { + linkURLReceived = url + } + + func checkoutDidEmitWebPixelEvent(event: PixelEvent) { + pixelEventReceived = event + } +}