From 1947e003132caf49a9fe4fd6882cd8878fcc4823 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 12 Aug 2025 13:57:38 +0100 Subject: [PATCH 1/6] Add support for Accelerated Checkouts --- .github/actions/install-cocoapods/action.yml | 5 +- .github/workflows/ci.yml | 4 +- .swiftlint.yml | 1 + dev.yml | 3 +- .../RNShopifyCheckoutSheetKit.podspec | 5 +- .../checkout-sheet-kit/ios/.clang-format | 29 ++ ...cceleratedCheckoutButtons+Extensions.swift | 68 +++ .../ios/AcceleratedCheckoutButtons.swift | 405 ++++++++++++++++++ ...yCheckoutSheetKit+EventSerialization.swift | 16 + .../ShopifyCheckoutSheetKit-Bridging-Header.h | 3 + .../ios/ShopifyCheckoutSheetKit.mm | 126 +++++- .../ios/ShopifyCheckoutSheetKit.swift | 89 ++++ .../@shopify/checkout-sheet-kit/package.json | 1 - .../checkout-sheet-kit/package.snapshot.json | 9 + .../components/AcceleratedCheckoutButtons.tsx | 336 +++++++++++++++ .../checkout-sheet-kit/src/context.tsx | 27 +- .../checkout-sheet-kit/src/index.d.ts | 77 +++- .../@shopify/checkout-sheet-kit/src/index.ts | 90 +++- .../tests/AcceleratedCheckoutButtons.test.tsx | 227 ++++++++++ .../checkout-sheet-kit/tests/context.test.tsx | 13 +- .../checkout-sheet-kit/tests/index.test.ts | 196 ++++++++- .../checkout-sheet-kit/tests/linking.test.ts | 8 + sample/.env.example | 1 + sample/Gemfile.lock | 4 +- sample/index.js | 10 +- sample/ios/Podfile | 4 +- sample/ios/Podfile.lock | 19 +- sample/ios/ReactNative/Info.plist | 35 +- .../ios/ReactNative/ReactNative.entitlements | 2 + .../CheckoutDidFailTests.swift | 2 +- .../ShopifyCheckoutSheetKitTests.swift | 2 +- sample/package.json | 1 + sample/scripts/test_ios | 5 +- sample/src/App.tsx | 225 ++++++++-- sample/src/context/Config.tsx | 72 +--- sample/src/context/Theme.tsx | 9 +- sample/src/hooks/useCheckoutEventHandlers.ts | 10 +- sample/src/screens/CartScreen.tsx | 99 +++-- sample/src/screens/CatalogScreen.tsx | 4 +- sample/src/screens/ProductDetailsScreen.tsx | 67 ++- sample/src/screens/SettingsScreen.tsx | 144 +++---- scripts/lint_swift | 8 +- snapshot.json | 52 --- turbo.json | 6 +- yarn.lock | 19 + 45 files changed, 2156 insertions(+), 382 deletions(-) create mode 100644 modules/@shopify/checkout-sheet-kit/ios/.clang-format create mode 100644 modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons+Extensions.swift create mode 100644 modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift create mode 100644 modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx create mode 100644 modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx delete mode 100644 snapshot.json diff --git a/.github/actions/install-cocoapods/action.yml b/.github/actions/install-cocoapods/action.yml index fd5bf62e..e31fc04d 100644 --- a/.github/actions/install-cocoapods/action.yml +++ b/.github/actions/install-cocoapods/action.yml @@ -23,10 +23,9 @@ runs: with: path: | sample/ios/Pods - key: ${{ runner.os }}-cocoapods-ruby-${{ inputs.ruby-version }}-${{ hashFiles('sample/ios/Podfile.lock', 'sample/Gemfile.lock', 'package.json', 'sample/package.json', 'modules/@shopify/checkout-sheet-kit/package.json', 'yarn.lock') }} + key: ${{ runner.os }}-cocoapods-ruby-${{ inputs.ruby-version }}-${{ hashFiles('sample/ios/Podfile.lock', 'sample/Gemfile.lock', 'sample/Gemfile', 'package.json', 'sample/package.json', 'modules/@shopify/checkout-sheet-kit/package.json', 'yarn.lock') }} - name: Install cocoapods - if: steps.cache-cocoapods.outputs.cache-hit != 'true' shell: bash env: NO_FLIPPER: "1" @@ -36,5 +35,5 @@ runs: cd sample bundle install cd ios - NO_FLIPPER=1 bundle exec pod install --deployment --repo-update + bundle exec pod install --deployment --repo-update cd $ROOT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be5b3558..6906e1ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,5 @@ jobs: ruby-version: ${{ env.RUBY_VERSION }} - name: Run Swift tests - timeout-minutes: 15 - # If turbo has already cached the build it will return instantly here run: | - yarn turbo run test:ios --cache-dir=".turbo" --no-daemon + yarn sample test:ios diff --git a/.swiftlint.yml b/.swiftlint.yml index ba0e1b9f..969e64a8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,6 +8,7 @@ disabled_rules: - opening_brace - function_body_length - trailing_comma + - function_parameter_count opt_in_rules: - array_init diff --git a/dev.yml b/dev.yml index 2689a580..dc09e79b 100644 --- a/dev.yml +++ b/dev.yml @@ -25,9 +25,10 @@ up: packages: - xcode + - swiftlint check: - lint_swift: yarn module lint:swift + lint_swift: ./scripts/lint_swift lint_module: yarn module lint lint_sample: yarn sample lint diff --git a/modules/@shopify/checkout-sheet-kit/RNShopifyCheckoutSheetKit.podspec b/modules/@shopify/checkout-sheet-kit/RNShopifyCheckoutSheetKit.podspec index 8181628d..dcda0186 100644 --- a/modules/@shopify/checkout-sheet-kit/RNShopifyCheckoutSheetKit.podspec +++ b/modules/@shopify/checkout-sheet-kit/RNShopifyCheckoutSheetKit.podspec @@ -20,7 +20,8 @@ Pod::Spec.new do |s| s.source_files = "ios/*.{h,m,mm,swift}" s.dependency "React-Core" - s.dependency "ShopifyCheckoutSheetKit", "~> 3.3.0" + s.dependency "ShopifyCheckoutSheetKit", "~> 3.4.0-rc.5" + s.dependency "ShopifyCheckoutSheetKit/AcceleratedCheckouts", "~> 3.4.0-rc.5" if fabric_enabled install_modules_dependencies(s) @@ -38,5 +39,5 @@ Pod::Spec.new do |s| s.dependency "RCTRequired" s.dependency "RCTTypeSafety" s.dependency "ReactCommon/turbomodule/core" - end + end end diff --git a/modules/@shopify/checkout-sheet-kit/ios/.clang-format b/modules/@shopify/checkout-sheet-kit/ios/.clang-format new file mode 100644 index 00000000..f2646986 --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/ios/.clang-format @@ -0,0 +1,29 @@ +BasedOnStyle: LLVM +Language: ObjC +UseTab: Never +IndentWidth: 2 +ContinuationIndentWidth: 2 +ColumnLimit: 120 + +# Keep long macro calls (e.g., RCT_EXTERN_METHOD) readable +AlignAfterOpenBracket: DontAlign +BinPackArguments: false +BinPackParameters: false +PenaltyBreakBeforeFirstCallParameter: 200 +ReflowComments: false + +# Objective-C specifics +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +ObjCBreakBeforeNestedBlockParam: true +PointerAlignment: Left +BreakBeforeBraces: Attach +SpaceBeforeParens: ControlStatements +SortIncludes: false + +# Treat common React Native macros as statements for nicer wrapping +StatementMacros: + - RCT_EXTERN_METHOD + - RCT_EXTERN_MODULE + - RCT_EXPORT_VIEW_PROPERTY + diff --git a/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons+Extensions.swift b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons+Extensions.swift new file mode 100644 index 00000000..5c8a485c --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons+Extensions.swift @@ -0,0 +1,68 @@ +/* + 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 _PassKit_SwiftUI +import Foundation +import PassKit +import SwiftUI + +// MARK: - Apple Pay Button + +@available(iOS 16.0, *) +extension PayWithApplePayButtonLabel { + static func from(_ string: String?, fallback: PayWithApplePayButtonLabel = .plain) -> PayWithApplePayButtonLabel { + guard let string else { + return fallback + } + return map[string] ?? .plain + } + + init?(fromString string: String?) { + guard let string, + let label = Self.map[string] + else { + return nil + } + self = label + } + + private static let map: [String: PayWithApplePayButtonLabel] = [ + "addMoney": .addMoney, + "book": .book, + "buy": .buy, + "checkout": .checkout, + "continue": .continue, + "contribute": .contribute, + "donate": .donate, + "inStore": .inStore, + "order": .order, + "plain": .plain, + "reload": .reload, + "rent": .rent, + "setUp": .setUp, + "subscribe": .subscribe, + "support": .support, + "tip": .tip, + "topUp": .topUp + ] +} diff --git a/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift new file mode 100644 index 00000000..6ad5ee72 --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift @@ -0,0 +1,405 @@ +/* + 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 +import PassKit +import React +import ShopifyCheckoutSheetKit +import SwiftUI +import UIKit + +// MARK: - AcceleratedCheckout Components + +@available(iOS 16.0, *) +class AcceleratedCheckoutConfiguration { + static let shared = AcceleratedCheckoutConfiguration() + var configuration: ShopifyAcceleratedCheckouts.Configuration? + var applePayConfiguration: ShopifyAcceleratedCheckouts.ApplePayConfiguration? + var defaultWallets: [Wallet] = [.applePay, .shopPay] + + var available: Bool { + if #available(iOS 16.0, *) { + return configuration != nil + } else { + return false + } + } + + var applePayAvailable: Bool { + if #available(iOS 16.0, *) { + return applePayConfiguration != nil + } else { + return false + } + } +} + +@objc(RCTAcceleratedCheckoutButtonsManager) +class RCTAcceleratedCheckoutButtonsManager: RCTViewManager { + override func view() -> UIView! { + if #available(iOS 16.0, *) { + return RCTAcceleratedCheckoutButtonsView() + } + + // Return an empty view for iOS < 17.0 (silent fallback) + return UIView() + } + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func constantsToExport() -> [AnyHashable: Any]! { + return [:] + } +} + +@available(iOS 16.0, *) +class RCTAcceleratedCheckoutButtonsView: UIView { + private var hostingController: UIHostingController? + private var configuration: ShopifyAcceleratedCheckouts.Configuration? + private weak var parentViewController: UIViewController? + + @objc var onSizeChange: RCTDirectEventBlock? + + // MARK: - Props + + /// Note that prop values are intentionally nil so that the kit defaults are used + + /** + * Accepts either { cartId } or { variantId, quantity }. + */ + @objc var checkoutIdentifier: NSDictionary? { + didSet { + updateView() + } + } + + @objc var cornerRadius: NSNumber? { + didSet { + updateView() + } + } + + @objc var wallets: [String]? { + didSet { + invalidateIntrinsicContentSize() + setNeedsLayout() + updateView() + } + } + + @objc var applePayLabel: String? { + didSet { + updateView() + } + } + + @objc var onFail: RCTBubblingEventBlock? + @objc var onComplete: RCTBubblingEventBlock? + @objc var onCancel: RCTBubblingEventBlock? + @objc var onRenderStateChange: RCTBubblingEventBlock? + @objc var onShouldRecoverFromError: RCTDirectEventBlock? + @objc var onWebPixelEvent: RCTBubblingEventBlock? + @objc var onClickLink: RCTBubblingEventBlock? + + // MARK: - Private + + /// Compute the wallets to render based on the `wallets` prop. + /// If `wallets` is provided and empty, render nothing. No fallback here; SDK provides defaults. + private var walletsToRender: [Wallet] { + guard let providedWallets = wallets else { return [] } + return convertToShopifyWallets(providedWallets) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + override func layoutSubviews() { + super.layoutSubviews() + hostingController?.view.frame = bounds + } + + override var intrinsicContentSize: CGSize { + let height = calculateRequiredHeight() + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + private func setupView() { + /// Configuration will be set via a static method from the main module + configuration = AcceleratedCheckoutConfiguration.shared.configuration + + /// Find the parent view controller + DispatchQueue.main.async { [weak self] in + self?.parentViewController = self?.findViewController() + } + + updateView() + + // Listen for configuration updates + NotificationCenter.default.addObserver( + self, + selector: #selector(configurationUpdated), + name: Notification.Name("AcceleratedCheckoutConfigurationUpdated"), + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(configurationUpdated), + name: Notification.Name("CheckoutKitConfigurationUpdated"), + object: nil + ) + + /// Fire initial size change event + resizeWallets() + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController + } + responder = nextResponder + } + return nil + } + + @objc private func configurationUpdated() { + configuration = AcceleratedCheckoutConfiguration.shared.configuration + updateView() + } + + private func attachModifiers(to buttons: AcceleratedCheckoutButtons, wallets: [Wallet]?, applePayLabel: PayWithApplePayButtonLabel?) -> AcceleratedCheckoutButtons { + var modifiedButtons = buttons + + if let wallets { + modifiedButtons = modifiedButtons.wallets(wallets) + } + + if let applePayLabel { + modifiedButtons = modifiedButtons.applePayLabel(applePayLabel) + } + + if let cornerRadius { + modifiedButtons = modifiedButtons.cornerRadius(CGFloat(cornerRadius.doubleValue)) + } + + return modifiedButtons + } + + private func attachEventListeners(to buttons: AcceleratedCheckoutButtons) -> AcceleratedCheckoutButtons { + return buttons + .onComplete { [weak self] event in + self?.handleCheckoutCompleted(event) + } + .onFail { [weak self] error in + self?.handleCheckoutFailed(error) + } + .onCancel { [weak self] in + self?.handleCheckoutCancelled() + } + .onRenderStateChange { [weak self] state in + self?.handleRenderStateChange(state) + } + .onClickLink { [weak self] url in + self?.handleClickLink(url) + } + .onWebPixelEvent { [weak self] event in + self?.handleWebPixelEvent(event) + } + } + + private func updateView() { + guard + let config = configuration, + let wallets, wallets != nil, wallets.count > 0, + let checkoutIdentifierDictionary = checkoutIdentifier as? [String: Any] + else { + renderEmptyView() + return + } + + /// Map wallets if provided; otherwise let the kit decide the defaults + let shopifyWallets = convertToShopifyWallets(wallets) + + var buttons: AcceleratedCheckoutButtons + + if let cartIdentifier = extractCartIdentifier(from: checkoutIdentifierDictionary) { + buttons = AcceleratedCheckoutButtons(cartID: cartIdentifier) + } else if let productIdentifier = extractProductIdentifier(from: checkoutIdentifierDictionary) { + buttons = AcceleratedCheckoutButtons( + variantID: productIdentifier.variantId, + quantity: productIdentifier.quantity + ) + } else { + renderEmptyView() + return + } + + /// Attach modifiers (wallets, applePayLabel, cornerRadius) + buttons = attachModifiers(to: buttons, wallets: shopifyWallets, applePayLabel: PayWithApplePayButtonLabel.from(applePayLabel)) + /// Attach event handlers + buttons = attachEventListeners(to: buttons) + + var view: AnyView + + /// Attach config (and Apple Pay config if available) + if let applePayConfig = AcceleratedCheckoutConfiguration.shared.applePayConfiguration { + view = AnyView(buttons.environmentObject(config).environmentObject(applePayConfig)) + } else { + view = AnyView(buttons.environmentObject(config)) + } + + if let hostingController { + hostingController.rootView = view + } else { + hostingController = UIHostingController(rootView: view) + hostingController?.view.backgroundColor = UIColor.clear + + // Ensure the hosting view can receive touch events + hostingController?.view.isUserInteractionEnabled = true + + if let hostingView = hostingController?.view { + addSubview(hostingView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: topAnchor), + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + } + + // Ensure this view can also receive touch events + isUserInteractionEnabled = true + + /// Fire size change event + resizeWallets() + } + + // MARK: - Event Handlers + + private func handleCheckoutCompleted(_ event: CheckoutCompletedEvent) { + onComplete?(ShopifyEventSerialization.serialize(checkoutCompletedEvent: event)) + } + + private func handleCheckoutFailed(_ error: CheckoutError) { + onFail?(ShopifyEventSerialization.serialize(checkoutError: error)) + } + + private func handleCheckoutCancelled() { + onCancel?([:]) + } + + private func handleRenderStateChange(_ state: RenderState) { + onRenderStateChange?(ShopifyEventSerialization.serialize(renderState: state)) + } + + private func handleWebPixelEvent(_ event: PixelEvent) { + onWebPixelEvent?(ShopifyEventSerialization.serialize(pixelEvent: event)) + } + + private func handleClickLink(_ url: URL) { + onClickLink?(ShopifyEventSerialization.serialize(clickEvent: url)) + } + + // MARK: - Helper Methods + + /// Parses `cartId` from `checkoutIdentifier` NSDictionary + private func extractCartIdentifier(from dictionary: [String: Any]) -> String? { + guard let rawCartId = dictionary["cartId"] as? String else { return nil } + let trimmedCartId = rawCartId.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedCartId.isEmpty ? nil : trimmedCartId + } + + /// Parses `variantId` and `quantity` from `checkoutIdentifier` NSDictionary + private func extractProductIdentifier(from dictionary: [String: Any]) -> (variantId: String, quantity: Int)? { + guard let rawVariantId = dictionary["variantId"] as? String else { return nil } + guard let rawQuantity = dictionary["quantity"] as? NSNumber else { return nil } + + let trimmedVariantId = rawVariantId.trimmingCharacters(in: .whitespacesAndNewlines) + let quantityValue = rawQuantity.intValue + + guard !trimmedVariantId.isEmpty, quantityValue > 0 else { return nil } + return (variantId: trimmedVariantId, quantity: quantityValue) + } + + private func renderEmptyView() { + hostingController?.rootView = AnyView(EmptyView()) + onSizeChange?(["height": 0]) + } + + private func calculateRequiredHeight() -> CGFloat { + /// If wallets prop is explicitly provided and maps to empty, height is zero + if wallets != nil, walletsToRender.isEmpty { + return 0 + } + + /// If wallets are provided and non-empty, use their count + if wallets != nil, !walletsToRender.isEmpty { + let numberOfWallets = max(walletsToRender.count, 1) + let buttonHeight: CGFloat = 48 + let gapHeight: CGFloat = 8 + return (CGFloat(numberOfWallets) * buttonHeight) + (CGFloat(numberOfWallets - 1) * gapHeight) + } + + let numberOfWallets = AcceleratedCheckoutConfiguration.shared.defaultWallets.count + let buttonHeight: CGFloat = 48 + let gapHeight: CGFloat = 8 + return (CGFloat(numberOfWallets) * buttonHeight) + (CGFloat(numberOfWallets - 1) * gapHeight) + } + + private func resizeWallets() { + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + + let height = self.calculateRequiredHeight() + self.onSizeChange?(["height": height]) + } + } + + private func convertToShopifyWallets(_ walletStrings: [String]) -> [Wallet] { + return walletStrings.compactMap { walletString in + switch walletString { + case "shopPay": + return .shopPay + case "applePay": + return .applePay + default: + return nil + } + } + } +} diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift index 0837ce6e..0d5445b0 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit+EventSerialization.swift @@ -156,4 +156,20 @@ internal enum ShopifyEventSerialization { ] } } + + /** + * Converts a RenderState enum to a string for React Native. + */ + static func serialize(renderState state: RenderState) -> [String: String] { + switch state { + case .loading: + return ["state": "loading"] + case .rendered: + return ["state": "rendered"] + case .error: + return ["state": "error"] + @unknown default: + return ["state": "unknown"] + } + } } diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit-Bridging-Header.h b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit-Bridging-Header.h index bf86fda3..3e860d9e 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit-Bridging-Header.h +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit-Bridging-Header.h @@ -23,3 +23,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO #import #import +#import +#import +#import diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm index ab37bb61..41c1648e 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.mm @@ -18,29 +18,131 @@ of this software and associated documentation files (the "Software"), to deal 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. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. */ #import +#import -@interface RCT_EXTERN_MODULE(RCTShopifyCheckoutSheetKit, NSObject) +@interface RCT_EXTERN_MODULE (RCTShopifyCheckoutSheetKit, NSObject) -/// Present checkout -RCT_EXTERN_METHOD(present:(NSString *)checkoutURLString); +/** + * Present checkout + */ +RCT_EXTERN_METHOD(present : (NSString*)checkoutURLString); -/// Preload checkout -RCT_EXTERN_METHOD(preload:(NSString *)checkoutURLString); +/** + * Preload checkout + */ +RCT_EXTERN_METHOD(preload : (NSString*)checkoutURLString); -/// Dismiss checkout +/** + * Dismiss checkout + */ RCT_EXTERN_METHOD(dismiss); -/// Invalidate preload cache +/** + * Invalidate preload cache + */ RCT_EXTERN_METHOD(invalidateCache); -/// Set configuration for checkout -RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration); +/** + * Set configuration for checkout + */ +RCT_EXTERN_METHOD(setConfig : (NSDictionary*)configuration); -// Return configuration for checkout -RCT_EXTERN_METHOD(getConfig: (RCTPromiseResolveBlock) resolve reject: (RCTPromiseRejectBlock) reject) +/** + * Return configuration for checkout + */ +RCT_EXTERN_METHOD(getConfig : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject) + +/** + * Configure AcceleratedCheckouts + */ +RCT_EXTERN_METHOD(configureAcceleratedCheckouts : (NSString*)storefrontDomain storefrontAccessToken : ( + NSString*)storefrontAccessToken customerEmail : (NSString*)customerEmail customerPhoneNumber : (NSString*) + customerPhoneNumber applePayMerchantIdentifier : (NSString*)applePayMerchantIdentifier applyPayContactFields : ( + NSArray*)applyPayContactFields resolve : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject); + +/** + * Check if accelerated checkout is available + */ +RCT_EXTERN_METHOD( + isAcceleratedCheckoutAvailable : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject); + +/** + * Check if Apple Pay is available + */ +RCT_EXTERN_METHOD(isApplePayAvailable : (RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject); + +@end + +/** + * AcceleratedCheckoutButtons View Manager + */ +@interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManager) + +/** + * Unified checkout identifier payload. + * Accepts either { cartId } or { variantId, quantity }. + */ +RCT_EXPORT_VIEW_PROPERTY(checkoutIdentifier, NSDictionary*) + +/** + * Corner radius for rendered buttons, in points. Defaults to 8. + */ +RCT_EXPORT_VIEW_PROPERTY(cornerRadius, NSNumber*) + +/** + * Wallets to render. Accepts an array of identifiers such as "shopPay" and "applePay". + * If omitted, native defaults are used. + */ +RCT_EXPORT_VIEW_PROPERTY(wallets, NSArray*) + +/** + * Label variant for the Apple Pay button (e.g., "plain", "buy", "checkout"). + */ +RCT_EXPORT_VIEW_PROPERTY(applePayLabel, NSString*) + +/** + * Emitted when checkout fails. Payload contains a CheckoutException-like shape. + */ +RCT_EXPORT_VIEW_PROPERTY(onFail, RCTBubblingEventBlock) + +/** + * Emitted when checkout completes successfully. Payload contains order details. + */ +RCT_EXPORT_VIEW_PROPERTY(onComplete, RCTBubblingEventBlock) + +/** + * Emitted when checkout is cancelled by the buyer. + */ +RCT_EXPORT_VIEW_PROPERTY(onCancel, RCTBubblingEventBlock) + +/** + * Emitted when the native render state changes. Values: "loading", "rendered", "error". + */ +RCT_EXPORT_VIEW_PROPERTY(onRenderStateChange, RCTBubblingEventBlock) + +/** + * Direct event used to determine whether native should attempt recovery from an error. + */ +RCT_EXPORT_VIEW_PROPERTY(onShouldRecoverFromError, RCTDirectEventBlock) + +/** + * Emitted when a web pixel event occurs during checkout. + */ +RCT_EXPORT_VIEW_PROPERTY(onWebPixelEvent, RCTBubblingEventBlock) + +/** + * Emitted when a link is clicked within the checkout experience. Payload contains the URL. + */ +RCT_EXPORT_VIEW_PROPERTY(onClickLink, RCTBubblingEventBlock) + +/** + * Emitted when the intrinsic height of the native view changes. Payload contains { height }. + */ +RCT_EXPORT_VIEW_PROPERTY(onSizeChange, RCTDirectEventBlock) @end diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index f17c5375..367f1d03 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -22,8 +22,10 @@ */ import Foundation +import PassKit import React import ShopifyCheckoutSheetKit +import SwiftUI import UIKit @objc(RCTShopifyCheckoutSheetKit) @@ -31,6 +33,8 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { private var hasListeners = false internal var checkoutSheet: UIViewController? + private var acceleratedCheckoutsConfiguration: Any? + private var acceleratedCheckoutsApplePayConfiguration: Any? override var methodQueue: DispatchQueue! { return DispatchQueue.main @@ -192,6 +196,8 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { if let closeButtonColorHex = iosConfig?["closeButtonColor"] as? String { ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = UIColor(hex: closeButtonColorHex) } + + NotificationCenter.default.post(name: Notification.Name("CheckoutKitConfigurationUpdated"), object: nil) } @objc func getConfig(_ resolve: @escaping RCTPromiseResolveBlock, reject _: @escaping RCTPromiseRejectBlock) { @@ -206,4 +212,87 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { resolve(config) } + + @objc func configureAcceleratedCheckouts( + _ storefrontDomain: String, + storefrontAccessToken: String, + customerEmail: String?, + customerPhoneNumber: String?, + applePayMerchantIdentifier: String?, + applyPayContactFields: [String]?, + resolve: @escaping RCTPromiseResolveBlock, + reject _: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + resolve(false) + return + } + + let customer = ShopifyAcceleratedCheckouts.Customer( + email: customerEmail, + phoneNumber: customerPhoneNumber + ) + + acceleratedCheckoutsConfiguration = ShopifyAcceleratedCheckouts.Configuration( + storefrontDomain: storefrontDomain, + storefrontAccessToken: storefrontAccessToken, + customer: customer + ) + + if let merchantIdentifier = applePayMerchantIdentifier, let contactFields = applyPayContactFields { + acceleratedCheckoutsApplePayConfiguration = ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: merchantIdentifier, + contactFields: contactFieldsToRequiredContactFields(contactFields) + ) + AcceleratedCheckoutConfiguration.shared.applePayConfiguration = acceleratedCheckoutsApplePayConfiguration as? ShopifyAcceleratedCheckouts.ApplePayConfiguration + } + + AcceleratedCheckoutConfiguration.shared.configuration = acceleratedCheckoutsConfiguration as? ShopifyAcceleratedCheckouts.Configuration + + NotificationCenter.default.post(name: Notification.Name("AcceleratedCheckoutConfigurationUpdated"), object: nil) + + resolve(true) + } + + @objc func isAcceleratedCheckoutAvailable( + _ resolve: @escaping RCTPromiseResolveBlock, + reject _: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + resolve(false) + return + } + + resolve(AcceleratedCheckoutConfiguration.shared.available) + } + + @objc func isApplePayAvailable( + _ resolve: @escaping RCTPromiseResolveBlock, + reject _: @escaping RCTPromiseRejectBlock + ) { + guard #available(iOS 16.0, *) else { + resolve(false) + return + } + + let available = AcceleratedCheckoutConfiguration.shared.available && AcceleratedCheckoutConfiguration.shared.applePayAvailable + + resolve(available) + } + + // MARK: - Private + + @available(iOS 16.0, *) + private func contactFieldsToRequiredContactFields(_ contactFields: [String]) -> [ShopifyAcceleratedCheckouts.RequiredContactFields] { + return contactFields.compactMap { + switch $0 { + case "email": + return ShopifyAcceleratedCheckouts.RequiredContactFields.email + case "phone": + return ShopifyAcceleratedCheckouts.RequiredContactFields.phone + default: + return nil + } + } + } } diff --git a/modules/@shopify/checkout-sheet-kit/package.json b/modules/@shopify/checkout-sheet-kit/package.json index f3c98b19..7a6684c9 100644 --- a/modules/@shopify/checkout-sheet-kit/package.json +++ b/modules/@shopify/checkout-sheet-kit/package.json @@ -15,7 +15,6 @@ "scripts": { "clean": "rm -rf lib", "build": "bob build", - "lint:swift": "./../../../sample/ios/Pods/SwiftLint/swiftlint --strict --quiet ios", "lint": "yarn typecheck && eslint src", "typecheck": "tsc --noEmit" }, diff --git a/modules/@shopify/checkout-sheet-kit/package.snapshot.json b/modules/@shopify/checkout-sheet-kit/package.snapshot.json index 3d8f03dc..782ccbaa 100644 --- a/modules/@shopify/checkout-sheet-kit/package.snapshot.json +++ b/modules/@shopify/checkout-sheet-kit/package.snapshot.json @@ -9,11 +9,15 @@ "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java", "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java", + "ios/AcceleratedCheckoutButtons.swift", + "ios/AcceleratedCheckoutButtons+Extensions.swift", "ios/ShopifyCheckoutSheetKit-Bridging-Header.h", "ios/ShopifyCheckoutSheetKit.mm", "ios/ShopifyCheckoutSheetKit.swift", "ios/ShopifyCheckoutSheetKit+EventSerialization.swift", "ios/ShopifyCheckoutSheetKit+Extensions.swift", + "lib/commonjs/components/AcceleratedCheckoutButtons.js", + "lib/commonjs/components/AcceleratedCheckoutButtons.js.map", "lib/commonjs/context.js", "lib/commonjs/context.js.map", "lib/commonjs/errors.d.js", @@ -26,6 +30,8 @@ "lib/commonjs/index.js.map", "lib/commonjs/pixels.d.js", "lib/commonjs/pixels.d.js.map", + "lib/module/components/AcceleratedCheckoutButtons.js", + "lib/module/components/AcceleratedCheckoutButtons.js.map", "lib/module/context.js", "lib/module/context.js.map", "lib/module/errors.d.js", @@ -38,11 +44,14 @@ "lib/module/index.js.map", "lib/module/pixels.d.js", "lib/module/pixels.d.js.map", + "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts", + "lib/typescript/src/components/AcceleratedCheckoutButtons.d.ts.map", "lib/typescript/src/context.d.ts", "lib/typescript/src/context.d.ts.map", "lib/typescript/src/index.d.ts", "lib/typescript/src/index.d.ts.map", "package.json", + "src/components/AcceleratedCheckoutButtons.tsx", "src/context.tsx", "src/errors.d.ts", "src/events.d.ts", diff --git a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx new file mode 100644 index 00000000..d7eaf57f --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx @@ -0,0 +1,336 @@ +/* +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 React, {useCallback, useMemo, useState} from 'react'; +import {requireNativeComponent, Platform} from 'react-native'; +import type {ViewStyle} from 'react-native'; +import type { + AcceleratedCheckoutWallet, + CheckoutCompletedEvent, + CheckoutException, + PixelEvent, +} from '..'; + +export enum RenderState { + Loading = 'loading', + Rendered = 'rendered', + Error = 'error', +} + +export enum ApplePayLabel { + addMoney = 'addMoney', + book = 'book', + buy = 'buy', + checkout = 'checkout', + continue = 'continue', + contribute = 'contribute', + donate = 'donate', + inStore = 'inStore', + order = 'order', + plain = 'plain', + reload = 'reload', + rent = 'rent', + setUp = 'setUp', + subscribe = 'subscribe', + support = 'support', + tip = 'tip', + topUp = 'topUp', +} + +type CheckoutIdentifier = + | { + cartId: string; + } + | { + variantId: string; + quantity: number; + }; + +interface CommonAcceleratedCheckoutButtonsProps { + /** + * Corner radius for the button (defaults to 8) + */ + cornerRadius?: number; + + /** + * Wallets to display in the button + * Defaults to both shopPay and applePay if not specified + */ + wallets?: AcceleratedCheckoutWallet[]; + + /** + * Label for the Apple Pay button + */ + applePayLabel?: ApplePayLabel; + + /** + * Called when checkout fails + */ + onFail?: (error: CheckoutException) => void; + + /** + * Called when checkout is completed successfully + */ + onComplete?: (event: CheckoutCompletedEvent) => void; + + /** + * Called when checkout is cancelled + */ + onCancel?: () => void; + + /** + * Called when the render state changes + * States from SDK: loading, rendered, error + */ + onRenderStateChange?: (state: RenderState, reason?: string) => void; + + /** + * Called when a web pixel event is triggered + */ + onWebPixelEvent?: (event: PixelEvent) => void; + + /** + * Called when a link is clicked within the checkout + */ + onClickLink?: (url: string) => void; + + /** + * Called when the size of the button changes + */ + onSizeChange?: (event: {nativeEvent: {height: number}}) => void; +} + +interface CartProps { + /** + * The cart ID for cart-based checkout + */ + cartId: string; +} + +interface VariantProps { + /** + * The variant ID for product-based checkout + */ + variantId: string; + + /** + * The quantity for product-based checkout + */ + quantity: number; +} + +type AcceleratedCheckoutButtonsProps = (CartProps | VariantProps) & + CommonAcceleratedCheckoutButtonsProps; + +interface NativeAcceleratedCheckoutButtonsProps { + applePayLabel?: string; + style?: ViewStyle; + checkoutIdentifier: CheckoutIdentifier; + cornerRadius?: number; + wallets?: AcceleratedCheckoutWallet[]; + onFail?: (event: {nativeEvent: CheckoutException}) => void; + onComplete?: (event: {nativeEvent: CheckoutCompletedEvent}) => void; + onCancel?: () => void; + onRenderStateChange?: (event: {nativeEvent: {state: string}}) => void; + onWebPixelEvent?: (event: {nativeEvent: PixelEvent}) => void; + onClickLink?: (event: {nativeEvent: {url: string}}) => void; + onSizeChange?: (event: {nativeEvent: {height: number}}) => void; +} + +const RCTAcceleratedCheckoutButtons = + requireNativeComponent( + 'RCTAcceleratedCheckoutButtons', + ); + +/** + * AcceleratedCheckoutButton provides pre-built payment UI components for Shop Pay and Apple Pay. + * It enables faster checkout with fewer steps and supports both cart and product page checkout. + * + * @example Cart-based checkout + * console.log('Checkout completed!', event.orderDetails)} + * onFail={(error) => console.error('Checkout failed:', error.message)} + * /> + * + * @example Product-based checkout + * console.log('Checkout completed!', event.orderDetails)} + * /> + */ + +export const AcceleratedCheckoutButtons: React.FC< + AcceleratedCheckoutButtonsProps +> = ({ + applePayLabel, + cornerRadius, + wallets, + onFail, + onComplete, + onCancel, + onRenderStateChange, + onWebPixelEvent, + onClickLink, + ...props +}) => { + const isCart = isCartProps(props); + const isVariant = isVariantProps(props); + const [dynamicHeight, setDynamicHeight] = useState( + undefined, + ); + + const handleFail = useCallback( + (event: {nativeEvent: CheckoutException}) => { + onFail?.(event.nativeEvent); + }, + [onFail], + ); + + const handleComplete = useCallback( + (event: {nativeEvent: CheckoutCompletedEvent}) => { + onComplete?.(event.nativeEvent); + }, + [onComplete], + ); + + const handleCancel = useCallback(() => { + onCancel?.(); + }, [onCancel]); + + const handleRenderStateChange = useCallback( + (event: {nativeEvent: {state: string; reason?: string}}) => { + if (event.nativeEvent?.state) { + if (isRenderStateError(event.nativeEvent.state)) { + onRenderStateChange?.( + event.nativeEvent.state, + event.nativeEvent.reason ?? '', + ); + return; + } + + onRenderStateChange?.(event.nativeEvent.state as RenderState); + } + }, + [onRenderStateChange], + ); + + const handleWebPixelEvent = useCallback( + (event: {nativeEvent: PixelEvent}) => { + onWebPixelEvent?.(event.nativeEvent); + }, + [onWebPixelEvent], + ); + + const handleClickLink = useCallback( + (event: {nativeEvent: {url: string}}) => { + if (event.nativeEvent?.url) { + onClickLink?.(event.nativeEvent.url); + } + }, + [onClickLink], + ); + + const handleSizeChange = useCallback( + (event: {nativeEvent: {height: number}}) => { + setDynamicHeight(event.nativeEvent.height); + }, + [], + ); + + const checkoutIdentifier: CheckoutIdentifier | undefined = useMemo(() => { + switch (true) { + case isCart: + return {cartId: props.cartId}; + case isVariant: + return {variantId: props.variantId, quantity: props.quantity}; + default: + return undefined; + } + }, [isCart, isVariant, props]); + + // Only render on iOS for now since ShopifyAcceleratedCheckouts is iOS-only + if (Platform.OS !== 'ios') { + return null; + } + + if (!checkoutIdentifier) { + /** + * @todo + * + * The ShopifyAcceleratedCheckouts module will handle this error by returning an empty view over the bridge + * to the javascript client. + * + * The onRenderStateChange event will be invoked with both an error state and a reason to indicate the error, at + * which point this error handling can be removed. + * + */ + + const error = new Error( + 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', + ); + if (__DEV__) { + throw error; + } else { + // eslint-disable-next-line no-console + console.warn(error.message); + return null; + } + } + + return ( + + ); +}; + +export default AcceleratedCheckoutButtons; + +function isRenderStateError(state: string): state is RenderState.Error { + return state === RenderState.Error; +} + +function isCartProps( + props: AcceleratedCheckoutButtonsProps, +): props is CartProps { + return 'cartId' in props; +} + +function isVariantProps( + props: AcceleratedCheckoutButtonsProps, +): props is VariantProps { + return 'variantId' in props && 'quantity' in props && props.quantity > 0; +} diff --git a/modules/@shopify/checkout-sheet-kit/src/context.tsx b/modules/@shopify/checkout-sheet-kit/src/context.tsx index ad9bf831..84a4e771 100644 --- a/modules/@shopify/checkout-sheet-kit/src/context.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/context.tsx @@ -21,7 +21,7 @@ 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, {useCallback, useMemo, useRef} from 'react'; +import React, {useCallback, useMemo, useRef, useEffect, useState} from 'react'; import type {PropsWithChildren} from 'react'; import {type EmitterSubscription} from 'react-native'; import {ShopifyCheckoutSheet} from './index'; @@ -36,6 +36,7 @@ import type { type Maybe = T | undefined; interface Context { + acceleratedCheckoutsAvailable: boolean; addEventListener: AddEventListener; getConfig: () => Promise; setConfig: (config: Configuration) => void; @@ -50,6 +51,7 @@ interface Context { const noop = () => undefined; const ShopifyCheckoutSheetContext = React.createContext({ + acceleratedCheckoutsAvailable: false, addEventListener: noop, removeEventListeners: noop, setConfig: noop, @@ -71,12 +73,33 @@ export function ShopifyCheckoutSheetProvider({ configuration, children, }: PropsWithChildren) { + const [acceleratedCheckoutsAvailable, setAcceleratedCheckoutsAvailable] = + useState(false); const instance = useRef(null); if (!instance.current) { instance.current = new ShopifyCheckoutSheet(configuration, features); } + useEffect(() => { + async function configureAcceleratedCheckouts() { + if (!instance.current || !configuration) { + return; + } + + if (configuration.acceleratedCheckouts) { + const ready = await instance.current?.configureAcceleratedCheckouts( + configuration.acceleratedCheckouts, + ); + setAcceleratedCheckoutsAvailable(ready); + } + + instance.current?.setConfig(configuration); + } + + configureAcceleratedCheckouts(); + }, [configuration]); + const addEventListener: AddEventListener = useCallback( (eventName, callback): EmitterSubscription | undefined => { return instance.current?.addEventListener(eventName, callback); @@ -118,6 +141,7 @@ export function ShopifyCheckoutSheetProvider({ const context = useMemo((): Context => { return { + acceleratedCheckoutsAvailable, addEventListener, dismiss, setConfig, @@ -129,6 +153,7 @@ export function ShopifyCheckoutSheetProvider({ version: instance.current?.version, }; }, [ + acceleratedCheckoutsAvailable, addEventListener, dismiss, removeEventListeners, diff --git a/modules/@shopify/checkout-sheet-kit/src/index.d.ts b/modules/@shopify/checkout-sheet-kit/src/index.d.ts index 1c9192d8..66365796 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.d.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.d.ts @@ -117,8 +117,9 @@ interface CommonConfiguration { title?: string; } -export type Configuration = CommonConfiguration & - ( +export type Configuration = CommonConfiguration & { + acceleratedCheckouts?: AcceleratedCheckoutConfiguration; +} & ( | { /** * The selected color scheme for the checkout. See README.md for more details. @@ -175,6 +176,66 @@ export type CheckoutEventCallback = | GeolocationRequestEventCallback | PixelEventCallback; +/** + * Available wallet types for accelerated checkout + */ +export enum AcceleratedCheckoutWallet { + shopPay = 'shopPay', + applePay = 'applePay', +} + +export enum ApplePayContactField { + email = 'email', + phone = 'phone', +} + +/** + * Configuration for AcceleratedCheckouts + */ +export interface AcceleratedCheckoutConfiguration { + /** + * The storefront domain (e.g., "your-shop.myshopify.com") + */ + storefrontDomain: string; + + /** + * The storefront access token with `write_cart_wallet_payments` scope + */ + storefrontAccessToken: string; + + /** + * Customer information for personalized checkout + */ + customer?: { + email?: string; + phoneNumber?: string; + }; + /** + * Enable and configure accelerated checkout wallets. + */ + wallets?: { + /** + * Apple Pay specific configuration. + * When provided, Apple Pay buttons can render and the Apple Pay sheet will + * request the specified buyer contact fields. + */ + applePay?: { + /** + * Buyer contact fields to request in the Apple Pay sheet. + * Supported values: + * - 'email': request the buyer's email address + * - 'phone': request the buyer's phone number + */ + contactFields: ApplePayContactField[]; + /** + * The Apple Merchant Identifier used to sign Apple Pay payment requests on iOS. + * Example: 'merchant.com.yourcompany' + */ + merchantIdentifier: string; + }; + }; +} + function addEventListener( event: 'close', callback: () => void, @@ -243,4 +304,16 @@ export interface ShopifyCheckoutSheetKit { * Cleans up any event callbacks to prevent memory leaks. */ teardown(): void; + + /** + * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons + */ + configureAcceleratedCheckouts( + config: AcceleratedCheckoutConfiguration, + ): Promise; + + /** + * Check if accelerated checkout is available for the given cart or product + */ + isAcceleratedCheckoutAvailable(): Promise; } diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 4dda85a7..3b7609a1 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -33,8 +33,9 @@ import type { PermissionStatus, } from 'react-native'; import {ShopifyCheckoutSheetProvider, useShopifyCheckoutSheet} from './context'; -import {ColorScheme} from './index.d'; +import {ApplePayContactField, ColorScheme} from './index.d'; import type { + AcceleratedCheckoutConfiguration, CheckoutEvent, CheckoutEventCallback, Configuration, @@ -43,6 +44,7 @@ import type { Maybe, ShopifyCheckoutSheetKit, } from './index.d'; +import {AcceleratedCheckoutWallet} from './index.d'; import type {CheckoutException, CheckoutNativeError} from './errors.d'; import { CheckoutExpiredError, @@ -56,6 +58,7 @@ import { import {CheckoutErrorCode} from './errors.d'; import type {CheckoutCompletedEvent} from './events.d'; import type {CustomEvent, PixelEvent, StandardEvent} from './pixels.d'; +import {ApplePayLabel} from './components/AcceleratedCheckoutButtons'; const RNShopifyCheckoutSheetKit = NativeModules.ShopifyCheckoutSheetKit; @@ -208,6 +211,45 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { this.geolocationCallback?.remove(); } + /** + * Configure AcceleratedCheckouts for Shop Pay and Apple Pay buttons + * @param config Configuration for AcceleratedCheckouts + */ + public async configureAcceleratedCheckouts( + config: AcceleratedCheckoutConfiguration, + ): Promise { + if (!this.acceleratedCheckoutsSupported) { + return false; + } + + this.validateAcceleratedCheckoutsConfiguration(config); + + const configured = + await RNShopifyCheckoutSheetKit.configureAcceleratedCheckouts( + config.storefrontDomain, + config.storefrontAccessToken, + config.customer?.email || null, + config.customer?.phoneNumber || null, + config.wallets?.applePay?.merchantIdentifier || null, + config.wallets?.applePay?.contactFields || [], + ); + + return configured; + } + + /** + * Check if accelerated checkout is available for the given cart or product + * @param options Options containing either cartId or variantId/quantity + * @returns Promise indicating availability + */ + public async isAcceleratedCheckoutAvailable(): Promise { + if (!this.acceleratedCheckoutsSupported) { + return false; + } + + return RNShopifyCheckoutSheetKit.isAcceleratedCheckoutAvailable(); + } + /** * Initiates a geolocation request for Android devices * Only needed if features.handleGeolocationRequests is false @@ -218,7 +260,41 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { } } - // --- + // --- private + + /** + * Accelerated Checkouts is only supported from iOS 16.0 onwards + */ + private get acceleratedCheckoutsSupported(): boolean { + return Platform.OS === 'ios' && this.majorVersion >= 16; + } + + private get majorVersion(): number { + return parseInt(String(Platform.Version), 10); + } + + private validateAcceleratedCheckoutsConfiguration( + acceleratedCheckouts: Configuration['acceleratedCheckouts'], + ) { + /** + * Required Accelerated Checkouts configuration properties + */ + if (!acceleratedCheckouts?.storefrontDomain) { + throw new Error('storefrontDomain is required'); + } + if (!acceleratedCheckouts.storefrontAccessToken) { + throw new Error('storefrontAccessToken is required'); + } + + /** + * Validate Apple Pay config if available + */ + if (acceleratedCheckouts.wallets?.applePay) { + if (!acceleratedCheckouts.wallets.applePay.merchantIdentifier) { + throw new Error('wallets.applePay.merchantIdentifier is required'); + } + } + } /** * Checks if a specific feature is enabled in the configuration @@ -375,6 +451,9 @@ export class LifecycleEventParseError extends Error { // API export { + ApplePayContactField, + ApplePayLabel, + AcceleratedCheckoutWallet, ColorScheme, ShopifyCheckoutSheet, ShopifyCheckoutSheetProvider, @@ -405,4 +484,11 @@ export type { Features, PixelEvent, StandardEvent, + AcceleratedCheckoutConfiguration, }; + +// Components +export { + AcceleratedCheckoutButtons, + RenderState, +} from './components/AcceleratedCheckoutButtons'; diff --git a/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx new file mode 100644 index 00000000..df92b27c --- /dev/null +++ b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import {Platform} from 'react-native'; +import { + AcceleratedCheckoutButtons, + AcceleratedCheckoutWallet, + RenderState, +} from '../src'; + +// Mock react-native Platform and requireNativeComponent +jest.mock('react-native', () => { + const mockRequireNativeComponent = jest.fn().mockImplementation(() => { + const mockComponent = (props: any) => { + // Use React.createElement with plain object instead + const mockReact = jest.requireActual('react'); + return mockReact.createElement('View', props); + }; + return mockComponent; + }); + + const mockShopifyCheckoutSheetKit = { + version: '0.7.0', + preload: jest.fn(), + present: jest.fn(), + invalidateCache: jest.fn(), + getConfig: jest.fn(async () => ({preloading: true})), + setConfig: jest.fn(), + addEventListener: jest.fn(), + removeEventListeners: jest.fn(), + initiateGeolocationRequest: jest.fn(), + configureAcceleratedCheckouts: jest.fn(), + isAcceleratedCheckoutAvailable: jest.fn(), + }; + + return { + Platform: { + OS: 'ios', + }, + requireNativeComponent: mockRequireNativeComponent, + NativeModules: { + ShopifyCheckoutSheetKit: mockShopifyCheckoutSheetKit, + }, + NativeEventEmitter: jest.fn(), + }; +}); + +const mockLog = jest.fn(); +// Silence console.error +const mockError = jest.fn(); + +beforeAll(() => { + global.console = { + ...global.console, + log: mockLog, + error: mockError, + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; +}); + +describe('AcceleratedCheckoutButtons', () => { + describe('iOS Platform', () => { + beforeEach(() => { + Platform.OS = 'ios'; + }); + + it('renders without crashing with cartId', () => { + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('renders without crashing with variant', () => { + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('renders without crashing with variant and quantity', () => { + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('passes through props to native component', () => { + const component = renderer.create( + , + ); + + const tree = component.toJSON(); + expect(tree).toBeTruthy(); + // @ts-expect-error tree is not null based on check above + expect(tree.props.checkoutIdentifier).toEqual({ + cartId: 'gid://shopify/Cart/123', + }); + // @ts-expect-error tree is not null based on check above + expect(tree.props.cornerRadius).toBe(12); + // @ts-expect-error tree is not null based on check above + expect(tree.props.wallets).toEqual([AcceleratedCheckoutWallet.shopPay]); + }); + + it('logs and returns null when neither cartId nor variantId is provided', () => { + expect(() => { + renderer.create( + , + ); + }).toThrow( + 'AcceleratedCheckoutButton: Either `cartId` or `variantId` and `quantity` must be provided', + ); + }); + + it('uses default values for cornerRadius', () => { + const component = renderer.create( + , + ); + + const tree = component.toJSON(); + expect(tree).toBeTruthy(); + // @ts-expect-error tree is not null based on check above + expect(tree.props.cornerRadius).toBeUndefined(); + }); + + it('passes through custom quantity and cornerRadius', () => { + const component = renderer.create( + , + ); + + const tree = component.toJSON(); + expect(tree).toBeTruthy(); + // @ts-expect-error tree is not null based on check above + expect(tree.props.cornerRadius).toBe(16); + }); + + it('supports custom wallet configuration', () => { + const customWallets = [ + AcceleratedCheckoutWallet.applePay, + AcceleratedCheckoutWallet.shopPay, + ]; + + const component = renderer.create( + , + ); + + const tree = component.toJSON(); + expect(tree).toBeTruthy(); + // @ts-expect-error tree is not null based on check above + expect(tree.props.wallets).toEqual(customWallets); + }); + + it('handles callbacks without throwing', () => { + const mockCallbacks = { + onFail: jest.fn(), + onComplete: jest.fn(), + onCancel: jest.fn(), + onRenderStateChange: jest.fn(), + onWebPixelEvent: jest.fn(), + onClickLink: jest.fn(), + }; + + expect(() => { + renderer.create( + , + ); + }).not.toThrow(); + }); + }); + + describe('Android Platform', () => { + beforeEach(() => { + Platform.OS = 'android'; + }); + + it('returns null on Android', () => { + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeNull(); + }); + + it('does not warn on Android even without required props', () => { + renderer.create(); + + expect(mockLog).not.toHaveBeenCalled(); + }); + }); + + describe('RenderState enum', () => { + it('exports correct render states', () => { + expect(RenderState.Loading).toBe('loading'); + expect(RenderState.Rendered).toBe('rendered'); + expect(RenderState.Error).toBe('error'); + }); + }); +}); diff --git a/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx index 6bf4989f..8d262178 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx +++ b/modules/@shopify/checkout-sheet-kit/tests/context.test.tsx @@ -12,7 +12,6 @@ const config: Configuration = { colorScheme: ColorScheme.automatic, }; -// Use the shared manual mock. Individual tests can override if needed. jest.mock('react-native'); // Helper component to test the hook @@ -66,21 +65,17 @@ describe('ShopifyCheckoutSheetProvider', () => { expect( NativeModules.ShopifyCheckoutSheetKit.setConfig, - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledWith(config); }); it('reuses the same instance across re-renders', () => { const component = create(test); - const firstCallCount = - NativeModules.ShopifyCheckoutSheetKit.setConfig.mock.calls.length; - component.update(updated); - const secondCallCount = - NativeModules.ShopifyCheckoutSheetKit.setConfig.mock.calls.length; - - expect(secondCallCount).toBe(firstCallCount); + expect( + NativeModules.ShopifyCheckoutSheetKit.setConfig.mock.calls, + ).toHaveLength(2); }); }); diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index e72b7298..601db404 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -10,8 +10,16 @@ import { CheckoutClientError, CheckoutExpiredError, GenericError, + AcceleratedCheckoutWallet, + RenderState, } from '../src'; -import {ColorScheme, CheckoutNativeErrorType, type Configuration} from '../src'; +import { + ColorScheme, + CheckoutNativeErrorType, + type Configuration, + type AcceleratedCheckoutConfiguration, +} from '../src'; +import type {ApplePayContactField} from '../src/index.d'; import {NativeModules, PermissionsAndroid, Platform} from 'react-native'; const checkoutUrl = 'https://shopify.com/checkout'; @@ -27,6 +35,23 @@ global.console = { warn: jest.fn(), }; +describe('Exports', () => { + describe('AcceleratedCheckoutWallet enum', () => { + it('exports correct wallet types', () => { + expect(AcceleratedCheckoutWallet.shopPay).toBe('shopPay'); + expect(AcceleratedCheckoutWallet.applePay).toBe('applePay'); + }); + }); + + describe('RenderState enum', () => { + it('exports correct render states', () => { + expect(RenderState.Loading).toBe('loading'); + expect(RenderState.Rendered).toBe('rendered'); + expect(RenderState.Error).toBe('error'); + }); + }); +}); + describe('ShopifyCheckoutSheetKit', () => { // @ts-expect-error "eventEmitter is private" const eventEmitter = ShopifyCheckoutSheet.eventEmitter; @@ -657,4 +682,173 @@ describe('ShopifyCheckoutSheetKit', () => { ); }); }); + + describe('Accelerated Checkout', () => { + const acceleratedConfig: AcceleratedCheckoutConfiguration = { + storefrontDomain: 'test-shop.myshopify.com', + storefrontAccessToken: 'shpat_test_token', + customer: { + email: 'test@example.com', + phoneNumber: '+1234567890', + }, + wallets: { + applePay: { + contactFields: ['email', 'phone'] as ApplePayContactField[], + merchantIdentifier: 'merchant.com.test', + }, + }, + }; + + beforeEach(() => { + Platform.OS = 'ios'; + Platform.Version = '17.0'; + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts.mockReset(); + NativeModules.ShopifyCheckoutSheetKit.isAcceleratedCheckoutAvailable.mockReset(); + }); + + describe('configureAcceleratedCheckouts', () => { + it('calls native configureAcceleratedCheckouts with correct parameters on iOS', async () => { + const instance = new ShopifyCheckoutSheet(); + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts.mockResolvedValue( + true, + ); + + const result = + await instance.configureAcceleratedCheckouts(acceleratedConfig); + + expect(result).toBe(true); + expect( + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts, + ).toHaveBeenCalledWith( + 'test-shop.myshopify.com', + 'shpat_test_token', + 'test@example.com', + '+1234567890', + 'merchant.com.test', + ['email', 'phone'], + ); + }); + + it('calls native configureAcceleratedCheckouts with null customer data when not provided', async () => { + const instance = new ShopifyCheckoutSheet(); + const minimalConfig = { + storefrontDomain: 'test-shop.myshopify.com', + storefrontAccessToken: 'shpat_test_token', + }; + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts.mockResolvedValue( + true, + ); + + await instance.configureAcceleratedCheckouts(minimalConfig); + + expect( + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts, + ).toHaveBeenCalledWith( + 'test-shop.myshopify.com', + 'shpat_test_token', + null, + null, + null, + [], + ); + }); + + it('returns false on Android', async () => { + Platform.OS = 'android'; + const instance = new ShopifyCheckoutSheet(); + + const result = + await instance.configureAcceleratedCheckouts(acceleratedConfig); + + expect(result).toBe(false); + expect( + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts, + ).not.toHaveBeenCalled(); + }); + + it('validates required storefrontDomain', async () => { + const instance = new ShopifyCheckoutSheet(); + const invalidConfig = { + ...acceleratedConfig, + storefrontDomain: '', + }; + + await expect( + instance.configureAcceleratedCheckouts(invalidConfig), + ).rejects.toThrow('storefrontDomain is required'); + }); + + it('validates required storefrontAccessToken', async () => { + const instance = new ShopifyCheckoutSheet(); + const invalidConfig = { + ...acceleratedConfig, + storefrontAccessToken: '', + }; + + await expect( + instance.configureAcceleratedCheckouts(invalidConfig), + ).rejects.toThrow('storefrontAccessToken is required'); + }); + + it('validates required merchantIdentifier when Apple Pay is configured', async () => { + const instance = new ShopifyCheckoutSheet(); + const invalidConfig = { + ...acceleratedConfig, + wallets: { + applePay: { + contactFields: ['email'] as ApplePayContactField[], + merchantIdentifier: '', + }, + }, + }; + + await expect( + instance.configureAcceleratedCheckouts(invalidConfig), + ).rejects.toThrow('wallets.applePay.merchantIdentifier is required'); + }); + + it('does not throw when Apple Pay wallet is not configured', async () => { + const instance = new ShopifyCheckoutSheet(); + const configWithoutApplePay = { + storefrontDomain: 'test-shop.myshopify.com', + storefrontAccessToken: 'shpat_test_token', + }; + NativeModules.ShopifyCheckoutSheetKit.configureAcceleratedCheckouts.mockResolvedValue( + true, + ); + + await expect( + instance.configureAcceleratedCheckouts(configWithoutApplePay), + ).resolves.toBe(true); + }); + }); + + describe('isAcceleratedCheckoutAvailable', () => { + it('calls native isAcceleratedCheckoutAvailable on iOS', async () => { + const instance = new ShopifyCheckoutSheet(); + NativeModules.ShopifyCheckoutSheetKit.isAcceleratedCheckoutAvailable.mockResolvedValue( + true, + ); + + const result = await instance.isAcceleratedCheckoutAvailable(); + + expect(result).toBe(true); + expect( + NativeModules.ShopifyCheckoutSheetKit.isAcceleratedCheckoutAvailable, + ).toHaveBeenCalledTimes(1); + }); + + it('returns false on Android', async () => { + Platform.OS = 'android'; + const instance = new ShopifyCheckoutSheet(); + + const result = await instance.isAcceleratedCheckoutAvailable(); + + expect(result).toBe(false); + expect( + NativeModules.ShopifyCheckoutSheetKit.isAcceleratedCheckoutAvailable, + ).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/modules/@shopify/checkout-sheet-kit/tests/linking.test.ts b/modules/@shopify/checkout-sheet-kit/tests/linking.test.ts index 23b99aeb..b2587f05 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/linking.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/linking.test.ts @@ -11,6 +11,14 @@ jest.mock('react-native', () => ({ Platform: { OS: 'ios', }, + requireNativeComponent: jest.fn().mockImplementation(() => { + const mockComponent = (props: any) => { + // Use React.createElement with plain object instead + const mockReact = jest.requireActual('react'); + return mockReact.createElement('View', props); + }; + return mockComponent; + }), })); describe('Native Module Linking', () => { diff --git a/sample/.env.example b/sample/.env.example index c9b08ac0..8d3e7369 100644 --- a/sample/.env.example +++ b/sample/.env.example @@ -1,6 +1,7 @@ # Storefront Details STOREFRONT_DOMAIN="YOUR_STORE.myshopify.com" STOREFRONT_ACCESS_TOKEN="YOUR_PUBLIC_STOREFRONT_ACCESS_TOKEN" +STOREFRONT_MERCHANT_IDENTIFIER=example.merchant.com.shopify # Storefront API version STOREFRONT_VERSION="2025-07" diff --git a/sample/Gemfile.lock b/sample/Gemfile.lock index 68d03eb0..75e35f64 100644 --- a/sample/Gemfile.lock +++ b/sample/Gemfile.lock @@ -71,7 +71,7 @@ GEM connection_pool (2.5.3) drb (2.2.3) escape (0.0.4) - ethon (0.16.0) + ethon (0.17.0) ffi (>= 1.15.0) ffi (1.17.2) fourflusher (2.3.1) @@ -117,7 +117,7 @@ DEPENDENCIES xcodeproj (< 1.26.0) RUBY VERSION - ruby 3.1.2p20 + ruby 3.3.6p108 BUNDLED WITH 2.5.23 diff --git a/sample/index.js b/sample/index.js index e2197204..4ddf52b2 100644 --- a/sample/index.js +++ b/sample/index.js @@ -25,6 +25,14 @@ import 'react-native-gesture-handler'; import SampleApp from './src/App'; import {name} from './app.json'; -import {AppRegistry} from 'react-native'; +import {AppRegistry, LogBox} from 'react-native'; + +/** + * Suppress the RCTImageView topError warning + * This is a known React Native issue that doesn't affect functionality + */ +LogBox.ignoreLogs([ + "Component 'RCTImageView' re-registered bubbling event 'topError' as a direct event", +]); AppRegistry.registerComponent(name, () => SampleApp); diff --git a/sample/ios/Podfile b/sample/ios/Podfile index bbca5991..13eb15ec 100644 --- a/sample/ios/Podfile +++ b/sample/ios/Podfile @@ -5,7 +5,7 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip -platform :ios, '13.4' +platform :ios, '16.6' prepare_react_native_project! @@ -55,6 +55,4 @@ target 'ReactNative' do end end -pod 'SwiftLint' - pod "RNShopifyCheckoutSheetKit", :path => "../../modules/@shopify/checkout-sheet-kit" diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index bbb121f3..00a1ba0e 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -1656,7 +1656,8 @@ PODS: - Yoga - RNShopifyCheckoutSheetKit (3.3.0): - React-Core - - ShopifyCheckoutSheetKit (~> 3.3.0) + - ShopifyCheckoutSheetKit (~> 3.4.0-rc.5) + - ShopifyCheckoutSheetKit/AcceleratedCheckouts (~> 3.4.0-rc.5) - RNVectorIcons (10.3.0): - DoubleConversion - glog @@ -1678,9 +1679,12 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - ShopifyCheckoutSheetKit (3.3.0) + - ShopifyCheckoutSheetKit (3.4.0-rc.5): + - ShopifyCheckoutSheetKit/Core (= 3.4.0-rc.5) + - ShopifyCheckoutSheetKit/AcceleratedCheckouts (3.4.0-rc.5): + - ShopifyCheckoutSheetKit/Core + - ShopifyCheckoutSheetKit/Core (3.4.0-rc.5) - SocketRocket (0.7.0) - - SwiftLint (0.59.1) - Yoga (0.0.0) DEPENDENCIES: @@ -1755,14 +1759,12 @@ DEPENDENCIES: - RNScreens (from `../node_modules/react-native-screens`) - "RNShopifyCheckoutSheetKit (from `../../modules/@shopify/checkout-sheet-kit`)" - RNVectorIcons (from `../node_modules/react-native-vector-icons`) - - SwiftLint - Yoga (from `../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: - ShopifyCheckoutSheetKit - SocketRocket - - SwiftLint EXTERNAL SOURCES: boost: @@ -1975,13 +1977,12 @@ SPEC CHECKSUMS: RNGestureHandler: 94ed503f49baffca0b1c048d5401c5a393a2660a RNReanimated: 9d34c9eb2e2265f66d0ca982f994088da24ca1f7 RNScreens: 4a5ab20b324ed1e3fe3862796b8f8e0a6208c415 - RNShopifyCheckoutSheetKit: 31d302a9150b461f5fac739bbdcc18cd0f750a6b + RNShopifyCheckoutSheetKit: e6682366288726b2165fd3aca0092ba0963dde99 RNVectorIcons: 18f6874f831ee0c755e31bb16b0f746c093fc0d8 - ShopifyCheckoutSheetKit: 5ee0c9753132d79924089ddef7e73ba4aa84fe37 + ShopifyCheckoutSheetKit: 0a0c5626057297d17ac47c39adb7672f40c5ac26 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d - SwiftLint: 3d48e2fb2a3468fdaccf049e5e755df22fb40c2c Yoga: 1dd9dabb9df8fe08f12cd522eae04a2da0e252eb -PODFILE CHECKSUM: 9efd19a381198fb46f36acf3d269233039fb9dc5 +PODFILE CHECKSUM: ef7c1248088910182f6b4aa083731a541b0da99f COCOAPODS: 1.15.2 diff --git a/sample/ios/ReactNative/Info.plist b/sample/ios/ReactNative/Info.plist index 49515ba6..eb830f68 100644 --- a/sample/ios/ReactNative/Info.plist +++ b/sample/ios/ReactNative/Info.plist @@ -20,8 +20,23 @@ $(MARKETING_VERSION) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + rn + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) + LSApplicationQueriesSchemes + + rn + LSRequiresIPhoneOS NSAppTransportSecurity @@ -35,6 +50,8 @@ NSLocationWhenInUseUsageDescription Your location is required to locate pickup points near you. + RCTNewArchEnabled + UIAppFonts Entypo.ttf @@ -53,23 +70,5 @@ UIViewControllerBasedStatusBarAppearance - CFBundleURLTypes - - - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER) - - - - CFBundleURLSchemes - - rn - - - - LSApplicationQueriesSchemes - - rn - diff --git a/sample/ios/ReactNative/ReactNative.entitlements b/sample/ios/ReactNative/ReactNative.entitlements index 01222282..08ba422c 100644 --- a/sample/ios/ReactNative/ReactNative.entitlements +++ b/sample/ios/ReactNative/ReactNative.entitlements @@ -7,5 +7,7 @@ webcredentials:myshopify.com applinks:myshopify.com + com.apple.developer.in-app-payments + diff --git a/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift b/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift index 50f16ad0..a8243e2d 100644 --- a/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift +++ b/sample/ios/ReactNativeTests/CheckoutDidFailTests.swift @@ -67,7 +67,7 @@ class CheckoutDidFailTests: XCTestCase { return XCTFail("Event body was not available or not in the correct format") } - if case let .checkoutExpired(message, code, recoverable) = error { + if case .checkoutExpired = error { XCTAssertEqual(eventBody["__typename"] as? String, "CheckoutExpiredError") XCTAssertEqual(eventBody["message"] as? String, "expired") XCTAssertEqual(eventBody["code"] as? String, CheckoutErrorCode.cartExpired.rawValue) diff --git a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift index 3f876eb8..9cf0db9b 100644 --- a/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift +++ b/sample/ios/ReactNativeTests/ShopifyCheckoutSheetKitTests.swift @@ -43,7 +43,7 @@ class ShopifyCheckoutSheetKitTests: XCTestCase { private func resetShopifyCheckoutSheetKitDefaults() { ShopifyCheckoutSheetKit.configuration.preloading = Configuration.Preloading(enabled: true) ShopifyCheckoutSheetKit.configuration.colorScheme = .automatic - ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = nil + ShopifyCheckoutSheetKit.configuration.closeButtonTintColor = nil } private func getShopifyCheckoutSheetKit() -> RCTShopifyCheckoutSheetKit { diff --git a/sample/package.json b/sample/package.json index 968f80ef..4edc34e8 100644 --- a/sample/package.json +++ b/sample/package.json @@ -27,6 +27,7 @@ "graphql": "^16.8.1", "jotai": "^2.13.1", "react-native-config": "^1.5.5", + "react-native-dotenv": "^3.4.11", "react-native-reanimated": "3.16.1", "react-native-safe-area-context": "^4.14.1", "react-native-screens": "4.4.0", diff --git a/sample/scripts/test_ios b/sample/scripts/test_ios index 0bbd38f7..1ec0fa69 100755 --- a/sample/scripts/test_ios +++ b/sample/scripts/test_ios @@ -1,7 +1,6 @@ #!/usr/bin/env bash -set -ex -set -eo pipefail +set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/simulator" @@ -9,7 +8,7 @@ dest="$(get_sim_destination)" cd ios -xcodebuild build-for-testing test \ +xcodebuild test \ -workspace ReactNative.xcworkspace \ -scheme ReactNative \ -destination "$dest" \ diff --git a/sample/src/App.tsx b/sample/src/App.tsx index 838f3dca..32bcbc21 100644 --- a/sample/src/App.tsx +++ b/sample/src/App.tsx @@ -22,8 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO */ import type {PropsWithChildren, ReactNode} from 'react'; -import React, {useEffect, useState} from 'react'; -import {Linking, StatusBar} from 'react-native'; +import React, {useEffect, useMemo, useState} from 'react'; +import {Appearance, Linking, StatusBar} from 'react-native'; import { Link, NavigationContainer, @@ -33,14 +33,14 @@ import { import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; import {ApolloClient, InMemoryCache, ApolloProvider} from '@apollo/client'; -import Config from 'react-native-config'; import Icon from 'react-native-vector-icons/Entypo'; import CatalogScreen from './screens/CatalogScreen'; import SettingsScreen from './screens/SettingsScreen'; -import type {Configuration} from '@shopify/checkout-sheet-kit'; +import type {Configuration, Features} from '@shopify/checkout-sheet-kit'; import { + ApplePayContactField, ColorScheme, ShopifyCheckoutSheetProvider, useShopifyCheckoutSheet, @@ -50,35 +50,45 @@ import type { CheckoutException, PixelEvent, } from '@shopify/checkout-sheet-kit'; -import {ConfigProvider} from './context/Config'; -import {ThemeProvider, getNavigationTheme, useTheme} from './context/Theme'; +import {ConfigProvider, useConfig} from './context/Config'; +import { + ThemeProvider, + darkColors, + getColors, + getNavigationTheme, + lightColors, + useTheme, +} from './context/Theme'; import {CartProvider, useCart} from './context/Cart'; import CartScreen from './screens/CartScreen'; import ProductDetailsScreen from './screens/ProductDetailsScreen'; import type {ProductVariant, ShopifyProduct} from '../@types'; import ErrorBoundary from './ErrorBoundary'; +import env from 'react-native-config'; +import {createDebugLogger} from './utils'; import {useShopifyEventHandlers} from './hooks/useCheckoutEventHandlers'; -const colorScheme = ColorScheme.web; +const log = createDebugLogger('ENV'); -const config: Configuration = { - colorScheme, - preloading: true, - colors: { - ios: { - backgroundColor: '#f0f0e8', - tintColor: '#2d2a38', - closeButtonColor: '#2d2a38', - }, - android: { - backgroundColor: '#f0f0e8', - progressIndicator: '#2d2a38', - headerBackgroundColor: '#f0f0e8', - headerTextColor: '#2d2a38', - closeButtonColor: '#2d2a38', - }, - }, -}; +function quote(str: string | undefined) { + return `"${str}"`; +} + +log('--------------------------------'); +log('Using the following env'); +log('STOREFRONT_DOMAIN:', quote(env.STOREFRONT_DOMAIN)); +log( + 'STOREFRONT_ACCESS_TOKEN:', + '*'.repeat(8) + env.STOREFRONT_ACCESS_TOKEN?.slice(-4), +); +log('STOREFRONT_VERSION:', quote(env.STOREFRONT_VERSION)); +log( + 'STOREFRONT_MERCHANT_IDENTIFIER:', + quote(env.STOREFRONT_MERCHANT_IDENTIFIER), +); +log('EMAIL:', quote(env.EMAIL)); +log('PHONE:', quote(env.PHONE)); +log('--------------------------------'); export type RootStackParamList = { Catalog: undefined; @@ -95,17 +105,23 @@ const Stack = createNativeStackNavigator(); export const cache = new InMemoryCache(); const client = new ApolloClient({ - uri: `https://${Config.STOREFRONT_DOMAIN}/api/${Config.STOREFRONT_VERSION}/graphql.json`, + uri: `https://${env.STOREFRONT_DOMAIN}/api/${env.STOREFRONT_VERSION}/graphql.json`, cache, headers: { 'Content-Type': 'application/json', - 'X-Shopify-Storefront-Access-Token': Config.STOREFRONT_ACCESS_TOKEN ?? '', + 'X-Shopify-Storefront-Access-Token': env.STOREFRONT_ACCESS_TOKEN ?? '', }, - connectToDevTools: true, + connectToDevTools: __DEV__, }); function AppWithTheme({children}: PropsWithChildren) { - return {children}; + const {colorScheme} = useTheme(); + + return ( + + {children} + + ); } const createNavigationIcon = @@ -164,9 +180,26 @@ class StorefrontURL { } } +const checkoutKitConfigDefaults: Configuration = { + colorScheme: ColorScheme.dark, + preloading: true, + colors: { + ios: { + backgroundColor: '#f0f0e8', + tintColor: '#2d2a38', + }, + android: { + backgroundColor: '#f0f0e8', + progressIndicator: '#2d2a38', + headerBackgroundColor: '#f0f0e8', + headerTextColor: '#2d2a38', + }, + }, +}; + function AppWithContext({children}: PropsWithChildren) { const shopify = useShopifyCheckoutSheet(); - const eventHandlers = useShopifyEventHandlers('App'); + const eventHandlers = useShopifyEventHandlers(); useEffect(() => { const close = shopify.addEventListener('close', () => { @@ -200,7 +233,14 @@ function AppWithContext({children}: PropsWithChildren) { }, [shopify, eventHandlers]); return ( - + @@ -215,7 +255,7 @@ function CatalogStack() { return ( { + if (appConfig.colorScheme === ColorScheme.automatic) { + return { + colorScheme: ColorScheme.automatic, + colors: { + ios: { + backgroundColor: updatedColors.webviewBackgroundColor, + tintColor: updatedColors.webViewProgressIndicator, + }, + android: { + light: { + backgroundColor: lightColors.webviewBackgroundColor, + progressIndicator: lightColors.webViewProgressIndicator, + headerBackgroundColor: lightColors.webviewBackgroundColor, + headerTextColor: lightColors.webviewHeaderTextColor, + closeButtonColor: lightColors.webviewCloseButtonColor, + }, + dark: { + backgroundColor: darkColors.webviewBackgroundColor, + progressIndicator: darkColors.webViewProgressIndicator, + headerBackgroundColor: darkColors.webviewBackgroundColor, + headerTextColor: darkColors.webviewHeaderTextColor, + closeButtonColor: darkColors.webviewCloseButtonColor, + }, + }, + }, + }; + } + + return { + colorScheme: appConfig.colorScheme, + colors: { + ios: { + backgroundColor: updatedColors.webviewBackgroundColor, + tintColor: updatedColors.webViewProgressIndicator, + closeButtonColor: updatedColors.webviewCloseButtonColor, + }, + android: { + backgroundColor: updatedColors.webviewBackgroundColor, + progressIndicator: updatedColors.webViewProgressIndicator, + headerBackgroundColor: updatedColors.webviewBackgroundColor, + headerTextColor: updatedColors.webviewHeaderTextColor, + closeButtonColor: updatedColors.webviewCloseButtonColor, + }, + }, + }; + }, [appConfig.colorScheme, updatedColors]); + + const checkoutKitConfig: Configuration = useMemo(() => { + return { + ...checkoutKitConfigDefaults, + ...checkoutKitThemeConfig, + preloading: appConfig.enablePreloading, + acceleratedCheckouts: { + storefrontDomain: env.STOREFRONT_DOMAIN!, + storefrontAccessToken: env.STOREFRONT_ACCESS_TOKEN!, + /** + * We're reading the customer email and phone number from the environment variables here, + * but in a real app you would derive these values from your backend. + */ + customer: appConfig.customerAuthenticated + ? { + email: env.EMAIL!, + phoneNumber: env.PHONE!, + } + : undefined, + wallets: { + applePay: { + /** + * In cases where customers are authenticated, email/phone will be provided through the customer property, + * In cases where customers are NOT authenticated, we will collect email and phone number via the Apple Pay sheet. + */ + contactFields: appConfig.customerAuthenticated + ? [] + : [ApplePayContactField.email, ApplePayContactField.phone], + merchantIdentifier: env.STOREFRONT_MERCHANT_IDENTIFIER!, + }, + }, + }, + } as Configuration; + }, [appConfig, checkoutKitThemeConfig]); + return ( - - {children} - + + + {children} + + ); } @@ -340,20 +473,20 @@ function Routes() { ); } +const checkoutKitFeatures: Partial = { + handleGeolocationRequests: true, +}; + function App() { return ( - - - - - - - - - + + + + + + + ); } diff --git a/sample/src/context/Config.tsx b/sample/src/context/Config.tsx index c39f2088..4960009e 100644 --- a/sample/src/context/Config.tsx +++ b/sample/src/context/Config.tsx @@ -6,85 +6,43 @@ import React, { useMemo, useState, } from 'react'; -import type {Configuration} from '@shopify/checkout-sheet-kit'; -import { - ColorScheme, - useShopifyCheckoutSheet, -} from '@shopify/checkout-sheet-kit'; +import {ColorScheme} from '@shopify/checkout-sheet-kit'; import {useTheme} from './Theme'; export interface AppConfig { + colorScheme: ColorScheme; + enablePreloading: boolean; prefillBuyerInformation: boolean; + customerAuthenticated: boolean; } interface Context { appConfig: AppConfig; - config: Configuration | undefined; - setConfig: (config: Configuration) => void; setAppConfig: (config: AppConfig) => void; } const defaultAppConfig: AppConfig = { + colorScheme: ColorScheme.automatic, + enablePreloading: true, prefillBuyerInformation: false, + customerAuthenticated: false, }; const ConfigContext = createContext({ appConfig: defaultAppConfig, - config: { - colorScheme: ColorScheme.automatic, - preloading: false, - }, - setConfig: () => undefined, setAppConfig: () => undefined, }); -export const ConfigProvider: React.FC = ({children}) => { - const ShopifyCheckout = useShopifyCheckoutSheet(); - const [config, setInternalConfig] = useState(undefined); +export const ConfigProvider: React.FC< + PropsWithChildren<{config?: AppConfig}> +> = ({children, config}) => { const [appConfig, setInternalAppConfig] = useState(defaultAppConfig); const {setColorScheme} = useTheme(); useEffect(() => { - async function init() { - try { - // Fetch the checkout configuration object - const config = await ShopifyCheckout.getConfig(); - // Store it in local state - setInternalConfig(config); - } catch (error) { - console.error('Failed to fetch Shopify checkout configuration', error); - } - } - - init(); - }, [ShopifyCheckout]); - - const setConfig = useCallback( - async (config: Configuration) => { - try { - // Update the SDK configuration - ShopifyCheckout.setConfig(config); - - // Fetch the latest configuration object - const updatedConfig = await ShopifyCheckout.getConfig(); - - // Update local config state - setInternalConfig(updatedConfig); - - // Update the color scheme theme setting if it changed - if (updatedConfig?.colorScheme) { - setColorScheme(updatedConfig.colorScheme); - } - - return updatedConfig; - } catch (error) { - console.error('Failed to configure Shopify checkout', error); - return undefined; - } - }, - [setColorScheme, ShopifyCheckout], - ); + setColorScheme(config?.colorScheme ?? ColorScheme.automatic); + }, [config, setColorScheme]); const setAppConfig = useCallback((config: AppConfig) => { setInternalAppConfig(config); @@ -92,14 +50,14 @@ export const ConfigProvider: React.FC = ({children}) => { const value = useMemo( () => ({ - config, appConfig, - setConfig, setAppConfig, }), - [appConfig, config, setAppConfig, setConfig], + [appConfig, setAppConfig], ); + console.log('[APP CONFIG]', appConfig); + return ( {children} ); diff --git a/sample/src/context/Theme.tsx b/sample/src/context/Theme.tsx index de91c8af..81e447df 100644 --- a/sample/src/context/Theme.tsx +++ b/sample/src/context/Theme.tsx @@ -6,6 +6,7 @@ import {DarkTheme, DefaultTheme} from '@react-navigation/native'; import {ColorScheme} from '@shopify/checkout-sheet-kit'; interface Context { + cornerRadius: number; colors: Colors; colorScheme: ColorScheme; preference: ColorSchemeName; @@ -67,6 +68,7 @@ export const webColors: Colors = { }; const ThemeContext = createContext({ + cornerRadius: 35, colorScheme: ColorScheme.automatic, colors: lightColors, preference: Appearance.getColorScheme(), @@ -167,8 +169,8 @@ export function getColors( } export const ThemeProvider: React.FC< - PropsWithChildren<{defaultValue: ColorScheme}> -> = ({children, defaultValue = ColorScheme.automatic}) => { + PropsWithChildren<{defaultValue: ColorScheme; cornerRadius: number}> +> = ({children, defaultValue = ColorScheme.automatic, cornerRadius}) => { const preference = useColorScheme(); const [colorScheme, setColorSchemeInternal] = useState(defaultValue); @@ -186,12 +188,13 @@ export const ThemeProvider: React.FC< const value = useMemo( () => ({ + cornerRadius, colors: getColors(colorScheme, preference), preference, colorScheme, setColorScheme, }), - [preference, colorScheme, setColorScheme], + [preference, colorScheme, setColorScheme, cornerRadius], ); return ( diff --git a/sample/src/hooks/useCheckoutEventHandlers.ts b/sample/src/hooks/useCheckoutEventHandlers.ts index 92556811..5101a37a 100644 --- a/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/sample/src/hooks/useCheckoutEventHandlers.ts @@ -7,14 +7,15 @@ import type { CheckoutCompletedEvent, CheckoutException, PixelEvent, + RenderState, } from '@shopify/checkout-sheet-kit'; import {Linking} from 'react-native'; interface EventHandlers { - onPress?: () => void; onFail?: (error: CheckoutException) => void; onComplete?: (event: CheckoutCompletedEvent) => void; onCancel?: () => void; + onRenderStateChange?: (state: RenderState) => void; onShouldRecoverFromError?: (error: {message: string}) => boolean; onWebPixelEvent?: (event: PixelEvent) => void; onClickLink?: (url: string) => void; @@ -25,11 +26,7 @@ export function useShopifyEventHandlers(name?: string): EventHandlers { return useMemo(() => { const log = createDebugLogger(name ?? ''); - return { - onPress: () => { - log('onPress'); - }, onFail: error => { log('onFail', error); }, @@ -40,6 +37,9 @@ export function useShopifyEventHandlers(name?: string): EventHandlers { onCancel: () => { log('onCancel'); }, + onRenderStateChange: state => { + log('onRenderStateChange', state); + }, onWebPixelEvent: event => { log('onWebPixelEvent', event.name); }, diff --git a/sample/src/screens/CartScreen.tsx b/sample/src/screens/CartScreen.tsx index 91003990..a7617bf1 100644 --- a/sample/src/screens/CartScreen.tsx +++ b/sample/src/screens/CartScreen.tsx @@ -35,14 +35,19 @@ import { } from 'react-native'; import Icon from 'react-native-vector-icons/Entypo'; -import {useShopifyCheckoutSheet} from '@shopify/checkout-sheet-kit'; +import { + useShopifyCheckoutSheet, + AcceleratedCheckoutButtons, + ApplePayLabel, + AcceleratedCheckoutWallet, +} from '@shopify/checkout-sheet-kit'; import useShopify from '../hooks/useShopify'; - -import type {CartLineItem, CartItem} from '../../@types'; +import type {CartLineItem} from '../../@types'; import type {Colors} from '../context/Theme'; import {useTheme} from '../context/Theme'; import {useCart} from '../context/Cart'; import {currency} from '../utils'; +import {useShopifyEventHandlers} from '../hooks/useCheckoutEventHandlers'; function CartScreen(): React.JSX.Element { const ShopifyCheckout = useShopifyCheckoutSheet(); @@ -50,11 +55,14 @@ function CartScreen(): React.JSX.Element { const {cartId, checkoutURL, totalQuantity, removeFromCart, addingToCart} = useCart(); const {queries} = useShopify(); + const eventHandlers = useShopifyEventHandlers( + 'Cart - AcceleratedCheckoutButtons', + ); const [fetchCart, {data, loading, error}] = queries.cart; - const {colors} = useTheme(); - const styles = createStyles(colors); + const {colors, cornerRadius} = useTheme(); + const styles = createStyles(colors, cornerRadius); useEffect(() => { if (cartId) { @@ -142,9 +150,7 @@ function CartScreen(): React.JSX.Element { Taxes - - {price(data.cart.cost.totalTaxAmount)} - + Estimated at checkout @@ -155,17 +161,39 @@ function CartScreen(): React.JSX.Element { - {totalQuantity > 0 && ( - - Checkout - - {totalQuantity} {totalQuantity === 1 ? 'item' : 'items'} -{' '} - {price(data.cart.cost.totalAmount)} - - + {totalQuantity > 0 && cartId && ( + + + + + + Checkout + + {price(data.cart.cost.totalAmount)} + + + + {/* Empty wallets, should not render anything */} + + + )} @@ -192,8 +220,8 @@ function CartItem({ loading?: boolean; onRemove: () => void; }) { - const {colors} = useTheme(); - const styles = createStyles(colors); + const {colors, cornerRadius} = useTheme(); + const styles = createStyles(colors, cornerRadius); return ( ; function ProductDetailsScreen({route}: Props) { - const {colors} = useTheme(); + const {colors, cornerRadius} = useTheme(); const {addToCart, addingToCart} = useCart(); - const styles = createStyles(colors); + const styles = createStyles(colors, cornerRadius); if (!route?.params) { return null; @@ -84,10 +90,15 @@ function ProductDetails({ loading?: boolean; onAddToCart: (variantId: string) => void; }) { - const {colors} = useTheme(); - const styles = createStyles(colors); + const {colors, cornerRadius} = useTheme(); + const styles = createStyles(colors, cornerRadius); const image = product.images?.edges[0]?.node; const variant = getVariant(product); + const {acceleratedCheckoutsAvailable} = useShopifyCheckoutSheet(); + + const eventHandlers = useShopifyEventHandlers( + 'PDP - AcceleratedCheckoutButtons', + ); return ( @@ -98,16 +109,30 @@ function ProductDetails({ style={styles.productImage} alt={image?.altText} source={{ - uri: image.thumbnailUrl, + uri: image.url, }} /> )} {product.title} - {product.description} + + {product.description.slice(0, 100)} ... + - + + + {acceleratedCheckoutsAvailable && variant?.id && ( + + )} + ) : ( - - Add to cart •{' '} - {currency(variant?.price.amount, variant?.price.currencyCode)} - + Add to cart )} @@ -129,7 +151,7 @@ function ProductDetails({ export default ProductDetailsScreen; -function createStyles(colors: Colors) { +function createStyles(colors: Colors, cornerRadius: number) { return StyleSheet.create({ container: { maxHeight: '100%', @@ -141,7 +163,7 @@ function createStyles(colors: Colors) { flex: 1, flexDirection: 'column', marginBottom: 10, - padding: 10, + padding: 20, backgroundColor: colors.backgroundSubdued, borderRadius: 5, }, @@ -180,22 +202,21 @@ function createStyles(colors: Colors) { width: '100%', height: 400, marginTop: 5, - borderRadius: 6, + borderRadius: cornerRadius, }, - addToCartButtonContainer: { - marginHorizontal: 5, + buttonContainer: { + marginTop: 20, + gap: 8, }, addToCartButton: { - borderRadius: 10, - fontSize: 8, - marginTop: 15, - marginBottom: 10, + borderRadius: cornerRadius, backgroundColor: colors.secondary, paddingHorizontal: 10, - paddingVertical: 18, + paddingVertical: 14, + height: 48, }, addToCartButtonText: { - fontSize: 14, + fontSize: 20, lineHeight: 20, color: colors.secondaryText, fontWeight: 'bold', diff --git a/sample/src/screens/SettingsScreen.tsx b/sample/src/screens/SettingsScreen.tsx index f05f1a7c..a1a9e14c 100644 --- a/sample/src/screens/SettingsScreen.tsx +++ b/sample/src/screens/SettingsScreen.tsx @@ -23,7 +23,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import React, {useCallback, useMemo} from 'react'; import { - Appearance, Pressable, SafeAreaView, SectionList, @@ -40,7 +39,7 @@ import { useShopifyCheckoutSheet, } from '@shopify/checkout-sheet-kit'; import type {Colors} from '../context/Theme'; -import {darkColors, getColors, lightColors, useTheme} from '../context/Theme'; +import {useTheme} from '../context/Theme'; import {useCart} from '../context/Cart'; enum SectionType { @@ -52,6 +51,7 @@ enum SectionType { interface SwitchItem { type: SectionType.Switch; title: string; + description?: string; value: boolean; handler: () => void; } @@ -89,93 +89,72 @@ interface SectionData { function SettingsScreen() { const ShopifyCheckout = useShopifyCheckoutSheet(); const {clearCart} = useCart(); - const {config, appConfig, setConfig, setAppConfig} = useConfig(); - const {colors} = useTheme(); + const {appConfig, setAppConfig} = useConfig(); + const {colors, setColorScheme} = useTheme(); const styles = createStyles(colors); - const handleColorSchemeChange = (item: SingleSelectItem) => { - const updatedColors = getColors(item.value, Appearance.getColorScheme()); - - if (item.value === ColorScheme.automatic) { - setConfig({ - colorScheme: ColorScheme.automatic, - colors: { - ios: { - backgroundColor: updatedColors.webviewBackgroundColor, - tintColor: updatedColors.webViewProgressIndicator, - }, - android: { - light: { - backgroundColor: lightColors.webviewBackgroundColor, - progressIndicator: lightColors.webViewProgressIndicator, - headerBackgroundColor: lightColors.webviewBackgroundColor, - headerTextColor: lightColors.webviewHeaderTextColor, - closeButtonColor: lightColors.webviewCloseButtonColor, - }, - dark: { - backgroundColor: darkColors.webviewBackgroundColor, - progressIndicator: darkColors.webViewProgressIndicator, - headerBackgroundColor: darkColors.webviewBackgroundColor, - headerTextColor: darkColors.webviewHeaderTextColor, - closeButtonColor: darkColors.webviewCloseButtonColor, - }, - }, - }, - }); - } else { - setConfig({ + const handleColorSchemeChange = useCallback( + (item: SingleSelectItem) => { + setAppConfig({ + ...appConfig, colorScheme: item.value, - colors: { - ios: { - backgroundColor: updatedColors.webviewBackgroundColor, - tintColor: updatedColors.webViewProgressIndicator, - closeButtonColor: updatedColors.webviewCloseButtonColor, - }, - android: { - backgroundColor: updatedColors.webviewBackgroundColor, - progressIndicator: updatedColors.webViewProgressIndicator, - headerBackgroundColor: updatedColors.webviewBackgroundColor, - headerTextColor: updatedColors.webviewHeaderTextColor, - closeButtonColor: updatedColors.webviewCloseButtonColor, - }, - }, }); - } - }; + setColorScheme(item.value); + }, + [appConfig, setAppConfig, setColorScheme], + ); const handleTogglePreloading = useCallback(() => { - setConfig({ - preloading: !config?.preloading, + setAppConfig({ + ...appConfig, + enablePreloading: !appConfig.enablePreloading, }); - }, [config?.preloading, setConfig]); + }, [appConfig, setAppConfig]); const handleTogglePrefill = useCallback(() => { clearCart(); setAppConfig({ + ...appConfig, prefillBuyerInformation: !appConfig.prefillBuyerInformation, + customerAuthenticated: !appConfig.customerAuthenticated, + }); + }, [appConfig, clearCart, setAppConfig]); + + const handleToggleCustomerAuthenticated = useCallback(() => { + setAppConfig({ + ...appConfig, + customerAuthenticated: !appConfig.customerAuthenticated, }); - }, [appConfig.prefillBuyerInformation, clearCart, setAppConfig]); + }, [appConfig, setAppConfig]); const configurationOptions: readonly SwitchItem[] = useMemo( () => [ { title: 'Preload checkout', type: SectionType.Switch, - value: config?.preloading ?? false, + value: appConfig.enablePreloading, handler: handleTogglePreloading, }, { title: 'Prefill buyer information', type: SectionType.Switch, - value: appConfig.prefillBuyerInformation ?? false, + value: appConfig.prefillBuyerInformation, handler: handleTogglePrefill, }, + { + title: 'Use authenticated customer', + description: + 'When toggled on, customer information will be attached to cart from your app settings. When toggled off, customer information will be collected from the Apple Pay sheet.', + type: SectionType.Switch, + value: appConfig.customerAuthenticated, + handler: handleToggleCustomerAuthenticated, + }, ], [ - appConfig.prefillBuyerInformation, - config?.preloading, + appConfig, handleTogglePrefill, handleTogglePreloading, + handleToggleCustomerAuthenticated, ], ); @@ -185,28 +164,28 @@ function SettingsScreen() { title: 'Automatic', type: SectionType.SingleSelect, value: ColorScheme.automatic, - selected: config?.colorScheme === ColorScheme.automatic, + selected: appConfig.colorScheme === ColorScheme.automatic, }, { title: 'Light', type: SectionType.SingleSelect, value: ColorScheme.light, - selected: config?.colorScheme === ColorScheme.light, + selected: appConfig.colorScheme === ColorScheme.light, }, { title: 'Dark', type: SectionType.SingleSelect, value: ColorScheme.dark, - selected: config?.colorScheme === ColorScheme.dark, + selected: appConfig.colorScheme === ColorScheme.dark, }, { title: 'Web', type: SectionType.SingleSelect, value: ColorScheme.web, - selected: config?.colorScheme === ColorScheme.web, + selected: appConfig.colorScheme === ColorScheme.web, }, ], - [config?.colorScheme], + [appConfig.colorScheme], ); const informationalItems: readonly TextItem[] = useMemo( @@ -226,11 +205,6 @@ function SettingsScreen() { type: SectionType.Text, value: Config.STOREFRONT_DOMAIN || 'undefined', }, - { - title: 'Storefront Access Token (last 4)', - type: SectionType.Text, - value: Config.STOREFRONT_ACCESS_TOKEN?.slice(-4) || 'undefined', - }, ], [ShopifyCheckout.version], ); @@ -310,16 +284,21 @@ interface TextItemProps { function SwitchItem({item, styles, onChange}: SwitchItemProps) { return ( - - {item.title} - + + + {item.title} + + + {item.description && ( + {item.description} + )} ); } @@ -364,9 +343,14 @@ function createStyles(colors: Colors) { listItemText: { flex: 1, fontSize: 16, - alignSelf: 'center', color: colors.text, }, + listItemDescription: { + color: colors.textSubdued, + fontSize: 12, + paddingHorizontal: 16, + paddingVertical: 10, + }, listItemSecondaryText: { color: colors.textSubdued, }, diff --git a/scripts/lint_swift b/scripts/lint_swift index 8de57529..1995895f 100755 --- a/scripts/lint_swift +++ b/scripts/lint_swift @@ -38,11 +38,11 @@ fi # Run SwiftLint if [[ "$MODE" == "check" ]]; then echo "🔄 Running SwiftLint in check mode..." - swiftlint lint --strict $DIR + swiftlint lint --strict $DIR --config .swiftlint.yml LINT_STATUS=$? else echo "🔄 Running SwiftLint in fix mode..." - swiftlint lint --fix $DIR + swiftlint lint --fix $DIR --config .swiftlint.yml LINT_STATUS=$? fi echo "SwiftLint exit status: $LINT_STATUS" @@ -50,11 +50,11 @@ echo "SwiftLint exit status: $LINT_STATUS" # Run SwiftFormat if [[ "$MODE" == "check" ]]; then echo "🔄 Running SwiftFormat in check mode..." - swiftformat $DIR --lint + swiftformat $DIR --lint --config .swiftformat FORMAT_STATUS=$? else echo "🔄 Running SwiftFormat in fix mode..." - swiftformat $DIR + swiftformat $DIR --config .swiftformat FORMAT_STATUS=$? fi echo "SwiftFormat exit status: $FORMAT_STATUS" diff --git a/snapshot.json b/snapshot.json deleted file mode 100644 index 3d8f03dc..00000000 --- a/snapshot.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - "LICENSE", - "RNShopifyCheckoutSheetKit.podspec", - "android/build.gradle", - "android/gradle.properties", - "android/proguard-rules.pro", - "android/src/main/AndroidManifest.xml", - "android/src/main/AndroidManifestNew.xml", - "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/CustomCheckoutEventProcessor.java", - "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitModule.java", - "android/src/main/java/com/shopify/reactnative/checkoutsheetkit/ShopifyCheckoutSheetKitPackage.java", - "ios/ShopifyCheckoutSheetKit-Bridging-Header.h", - "ios/ShopifyCheckoutSheetKit.mm", - "ios/ShopifyCheckoutSheetKit.swift", - "ios/ShopifyCheckoutSheetKit+EventSerialization.swift", - "ios/ShopifyCheckoutSheetKit+Extensions.swift", - "lib/commonjs/context.js", - "lib/commonjs/context.js.map", - "lib/commonjs/errors.d.js", - "lib/commonjs/errors.d.js.map", - "lib/commonjs/events.d.js", - "lib/commonjs/events.d.js.map", - "lib/commonjs/index.d.js", - "lib/commonjs/index.d.js.map", - "lib/commonjs/index.js", - "lib/commonjs/index.js.map", - "lib/commonjs/pixels.d.js", - "lib/commonjs/pixels.d.js.map", - "lib/module/context.js", - "lib/module/context.js.map", - "lib/module/errors.d.js", - "lib/module/errors.d.js.map", - "lib/module/events.d.js", - "lib/module/events.d.js.map", - "lib/module/index.d.js", - "lib/module/index.d.js.map", - "lib/module/index.js", - "lib/module/index.js.map", - "lib/module/pixels.d.js", - "lib/module/pixels.d.js.map", - "lib/typescript/src/context.d.ts", - "lib/typescript/src/context.d.ts.map", - "lib/typescript/src/index.d.ts", - "lib/typescript/src/index.d.ts.map", - "package.json", - "src/context.tsx", - "src/errors.d.ts", - "src/events.d.ts", - "src/index.d.ts", - "src/index.ts", - "src/pixels.d.ts" -] diff --git a/turbo.json b/turbo.json index 96f41df9..2d8a88a3 100644 --- a/turbo.json +++ b/turbo.json @@ -14,10 +14,12 @@ "outputs": ["sample/ios/build"] }, "test:ios": { - "dependsOn": ["build", "lint"] + "dependsOn": ["build", "lint"], + "cache": false }, "test:android": { - "dependsOn": ["build", "lint"] + "dependsOn": ["build", "lint"], + "cache": false } } } diff --git a/yarn.lock b/yarn.lock index 446376cd..e6070ece 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5918,6 +5918,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^16.4.5": + version: 16.6.1 + resolution: "dotenv@npm:16.6.1" + checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -10291,6 +10298,17 @@ __metadata: languageName: node linkType: hard +"react-native-dotenv@npm:^3.4.11": + version: 3.4.11 + resolution: "react-native-dotenv@npm:3.4.11" + dependencies: + dotenv: "npm:^16.4.5" + peerDependencies: + "@babel/runtime": ^7.20.6 + checksum: 10c0/9ed4a37fb59030706d17a1cc49d05af9d8fc57dcb2ef97e122b844f3ab6f6331ebc089afac559fe5bddf37806aa282b4118005f1b27aae5e37fdc3fc8bd7625c + languageName: node + linkType: hard + "react-native-dotenv@npm:^3.4.9": version: 3.4.9 resolution: "react-native-dotenv@npm:3.4.9" @@ -10899,6 +10917,7 @@ __metadata: graphql: "npm:^16.8.1" jotai: "npm:^2.13.1" react-native-config: "npm:^1.5.5" + react-native-dotenv: "npm:^3.4.11" react-native-reanimated: "npm:3.16.1" react-native-safe-area-context: "npm:^4.14.1" react-native-screens: "npm:4.4.0" From 92950d5917c5271d984b0c4a481860daa57cd687 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 26 Aug 2025 10:20:27 +0100 Subject: [PATCH 2/6] Handle version checkout in tsx component, update tests --- .../components/AcceleratedCheckoutButtons.tsx | 2 +- .../tests/AcceleratedCheckoutButtons.test.tsx | 67 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx index d7eaf57f..0b45efa2 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx @@ -271,7 +271,7 @@ export const AcceleratedCheckoutButtons: React.FC< }, [isCart, isVariant, props]); // Only render on iOS for now since ShopifyAcceleratedCheckouts is iOS-only - if (Platform.OS !== 'ios') { + if (Platform.OS !== 'ios' || parseInt(Platform.Version, 10) < 16) { return null; } diff --git a/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx index df92b27c..b35e8bf7 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx +++ b/modules/@shopify/checkout-sheet-kit/tests/AcceleratedCheckoutButtons.test.tsx @@ -35,6 +35,7 @@ jest.mock('react-native', () => { return { Platform: { OS: 'ios', + Version: '16.0', }, requireNativeComponent: mockRequireNativeComponent, NativeModules: { @@ -65,6 +66,72 @@ describe('AcceleratedCheckoutButtons', () => { describe('iOS Platform', () => { beforeEach(() => { Platform.OS = 'ios'; + // Default to iOS 16 for most tests + (Platform as any).Version = '16.0'; + }); + + describe('iOS Version Compatibility', () => { + it('returns null on iOS versions below 16', () => { + (Platform as any).Version = '15.5'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeNull(); + }); + + it('returns null on iOS 14', () => { + (Platform as any).Version = '14.0'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeNull(); + }); + + it('renders on iOS 16', () => { + (Platform as any).Version = '16.0'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('renders on iOS 17', () => { + (Platform as any).Version = '17.0'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('handles iOS version with decimal correctly', () => { + (Platform as any).Version = '16.4.1'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeTruthy(); + }); + + it('does not warn when returning null for iOS < 16', () => { + (Platform as any).Version = '15.0'; + + const component = renderer.create( + , + ); + + expect(component.toJSON()).toBeNull(); + expect(mockLog).not.toHaveBeenCalled(); + expect(mockError).not.toHaveBeenCalled(); + }); }); it('renders without crashing with cartId', () => { From 2c14d9f43681e2c92f32acbd66d2960ca518ddea Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 26 Aug 2025 10:44:43 +0100 Subject: [PATCH 3/6] Bump package version to 3.4.0-rc.1 --- modules/@shopify/checkout-sheet-kit/package.json | 2 +- sample/ios/Podfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/package.json b/modules/@shopify/checkout-sheet-kit/package.json index 7a6684c9..eb8362aa 100644 --- a/modules/@shopify/checkout-sheet-kit/package.json +++ b/modules/@shopify/checkout-sheet-kit/package.json @@ -1,7 +1,7 @@ { "name": "@shopify/checkout-sheet-kit", "license": "MIT", - "version": "3.3.0", + "version": "3.4.0-rc.1", "main": "lib/commonjs/index.js", "types": "src/index.ts", "source": "src/index.ts", diff --git a/sample/ios/Podfile.lock b/sample/ios/Podfile.lock index 00a1ba0e..3c043bac 100644 --- a/sample/ios/Podfile.lock +++ b/sample/ios/Podfile.lock @@ -1654,7 +1654,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - RNShopifyCheckoutSheetKit (3.3.0): + - RNShopifyCheckoutSheetKit (3.4.0-rc.1): - React-Core - ShopifyCheckoutSheetKit (~> 3.4.0-rc.5) - ShopifyCheckoutSheetKit/AcceleratedCheckouts (~> 3.4.0-rc.5) @@ -1977,7 +1977,7 @@ SPEC CHECKSUMS: RNGestureHandler: 94ed503f49baffca0b1c048d5401c5a393a2660a RNReanimated: 9d34c9eb2e2265f66d0ca982f994088da24ca1f7 RNScreens: 4a5ab20b324ed1e3fe3862796b8f8e0a6208c415 - RNShopifyCheckoutSheetKit: e6682366288726b2165fd3aca0092ba0963dde99 + RNShopifyCheckoutSheetKit: 970346698720e3489cb6331263b076233d94799a RNVectorIcons: 18f6874f831ee0c755e31bb16b0f746c093fc0d8 ShopifyCheckoutSheetKit: 0a0c5626057297d17ac47c39adb7672f40c5ac26 SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d From 662715084c12971838fd20b624f01d111385dc99 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 26 Aug 2025 11:45:18 +0100 Subject: [PATCH 4/6] Improvements --- .../ios/AcceleratedCheckoutButtons.swift | 2 +- .../ios/ShopifyCheckoutSheetKit.swift | 26 +++++++++---- .../components/AcceleratedCheckoutButtons.tsx | 39 ++++++++++++------- .../checkout-sheet-kit/src/context.tsx | 4 +- .../@shopify/checkout-sheet-kit/src/index.ts | 28 ++++++++----- sample/ios/ReactNative/Info.plist | 2 - sample/src/hooks/useCheckoutEventHandlers.ts | 7 ++-- 7 files changed, 66 insertions(+), 42 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift index 6ad5ee72..a2e341a8 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/AcceleratedCheckoutButtons.swift @@ -61,7 +61,7 @@ class RCTAcceleratedCheckoutButtonsManager: RCTViewManager { return RCTAcceleratedCheckoutButtonsView() } - // Return an empty view for iOS < 17.0 (silent fallback) + // Return an empty view for iOS < 16.0 (silent fallback) return UIView() } diff --git a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift index 367f1d03..5f294508 100644 --- a/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift +++ b/modules/@shopify/checkout-sheet-kit/ios/ShopifyCheckoutSheetKit.swift @@ -240,11 +240,19 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { ) if let merchantIdentifier = applePayMerchantIdentifier, let contactFields = applyPayContactFields { - acceleratedCheckoutsApplePayConfiguration = ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: merchantIdentifier, - contactFields: contactFieldsToRequiredContactFields(contactFields) - ) - AcceleratedCheckoutConfiguration.shared.applePayConfiguration = acceleratedCheckoutsApplePayConfiguration as? ShopifyAcceleratedCheckouts.ApplePayConfiguration + do { + let fields = try contactFieldsToRequiredContactFields(contactFields) + + acceleratedCheckoutsApplePayConfiguration = ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: merchantIdentifier, + contactFields: fields + ) + + AcceleratedCheckoutConfiguration.shared.applePayConfiguration = acceleratedCheckoutsApplePayConfiguration as? ShopifyAcceleratedCheckouts.ApplePayConfiguration + } catch { + resolve(false) + return + } } AcceleratedCheckoutConfiguration.shared.configuration = acceleratedCheckoutsConfiguration as? ShopifyAcceleratedCheckouts.Configuration @@ -283,15 +291,17 @@ class RCTShopifyCheckoutSheetKit: RCTEventEmitter, CheckoutDelegate { // MARK: - Private @available(iOS 16.0, *) - private func contactFieldsToRequiredContactFields(_ contactFields: [String]) -> [ShopifyAcceleratedCheckouts.RequiredContactFields] { - return contactFields.compactMap { + private func contactFieldsToRequiredContactFields(_ contactFields: [String]) throws -> [ShopifyAcceleratedCheckouts.RequiredContactFields] { + return try contactFields.compactMap { switch $0 { case "email": return ShopifyAcceleratedCheckouts.RequiredContactFields.email case "phone": return ShopifyAcceleratedCheckouts.RequiredContactFields.phone default: - return nil + let message = "Unknown contactField option: \(String(describing: $0))" + print("[ShopifyCheckoutSheetKit] \(message)") + throw NSError(domain: "ShopifyCheckoutSheetKit", code: 1, userInfo: ["message": message]) } } } diff --git a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx index 0b45efa2..399e8611 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx @@ -35,6 +35,7 @@ export enum RenderState { Loading = 'loading', Rendered = 'rendered', Error = 'error', + Unknown = 'unknown', } export enum ApplePayLabel { @@ -102,7 +103,13 @@ interface CommonAcceleratedCheckoutButtonsProps { * Called when the render state changes * States from SDK: loading, rendered, error */ - onRenderStateChange?: (state: RenderState, reason?: string) => void; + onRenderStateChange?: ( + event: + | {state: RenderState.Error; reason?: string} + | {state: RenderState.Loading} + | {state: RenderState.Rendered} + | {state: RenderState.Unknown}, + ) => void; /** * Called when a web pixel event is triggered @@ -151,7 +158,9 @@ interface NativeAcceleratedCheckoutButtonsProps { onFail?: (event: {nativeEvent: CheckoutException}) => void; onComplete?: (event: {nativeEvent: CheckoutCompletedEvent}) => void; onCancel?: () => void; - onRenderStateChange?: (event: {nativeEvent: {state: string}}) => void; + onRenderStateChange?: (event: { + nativeEvent: {state: string; reason?: string | undefined}; + }) => void; onWebPixelEvent?: (event: {nativeEvent: PixelEvent}) => void; onClickLink?: (event: {nativeEvent: {url: string}}) => void; onSizeChange?: (event: {nativeEvent: {height: number}}) => void; @@ -220,17 +229,14 @@ export const AcceleratedCheckoutButtons: React.FC< }, [onCancel]); const handleRenderStateChange = useCallback( - (event: {nativeEvent: {state: string; reason?: string}}) => { - if (event.nativeEvent?.state) { - if (isRenderStateError(event.nativeEvent.state)) { - onRenderStateChange?.( - event.nativeEvent.state, - event.nativeEvent.reason ?? '', - ); - return; - } - - onRenderStateChange?.(event.nativeEvent.state as RenderState); + (event: {nativeEvent: {state: string; reason?: string | undefined}}) => { + const state = validRenderState(event.nativeEvent.state); + const reason = event.nativeEvent.reason; + + if (state === RenderState.Error) { + onRenderStateChange?.({state, reason}); + } else { + onRenderStateChange?.({state}); } }, [onRenderStateChange], @@ -319,8 +325,11 @@ export const AcceleratedCheckoutButtons: React.FC< export default AcceleratedCheckoutButtons; -function isRenderStateError(state: string): state is RenderState.Error { - return state === RenderState.Error; +function validRenderState(state: string): RenderState { + return ( + Object.values(RenderState).find(renderState => renderState === state) ?? + RenderState.Unknown + ); } function isCartProps( diff --git a/modules/@shopify/checkout-sheet-kit/src/context.tsx b/modules/@shopify/checkout-sheet-kit/src/context.tsx index 84a4e771..ea91a420 100644 --- a/modules/@shopify/checkout-sheet-kit/src/context.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/context.tsx @@ -82,7 +82,7 @@ export function ShopifyCheckoutSheetProvider({ } useEffect(() => { - async function configureAcceleratedCheckouts() { + async function configureCheckoutKit() { if (!instance.current || !configuration) { return; } @@ -97,7 +97,7 @@ export function ShopifyCheckoutSheetProvider({ instance.current?.setConfig(configuration); } - configureAcceleratedCheckouts(); + configureCheckoutKit(); }, [configuration]); const addEventListener: AddEventListener = useCallback( diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 3b7609a1..7fb2fedf 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -224,17 +224,25 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { this.validateAcceleratedCheckoutsConfiguration(config); - const configured = - await RNShopifyCheckoutSheetKit.configureAcceleratedCheckouts( - config.storefrontDomain, - config.storefrontAccessToken, - config.customer?.email || null, - config.customer?.phoneNumber || null, - config.wallets?.applePay?.merchantIdentifier || null, - config.wallets?.applePay?.contactFields || [], + try { + const configured = + await RNShopifyCheckoutSheetKit.configureAcceleratedCheckouts( + config.storefrontDomain, + config.storefrontAccessToken, + config.customer?.email || null, + config.customer?.phoneNumber || null, + config.wallets?.applePay?.merchantIdentifier || null, + config.wallets?.applePay?.contactFields || [], + ); + return configured; + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Failed to configured accelerated checkouts with config:', + config, ); - - return configured; + return false; + } } /** diff --git a/sample/ios/ReactNative/Info.plist b/sample/ios/ReactNative/Info.plist index eb830f68..03b9b82e 100644 --- a/sample/ios/ReactNative/Info.plist +++ b/sample/ios/ReactNative/Info.plist @@ -50,8 +50,6 @@ NSLocationWhenInUseUsageDescription Your location is required to locate pickup points near you. - RCTNewArchEnabled - UIAppFonts Entypo.ttf diff --git a/sample/src/hooks/useCheckoutEventHandlers.ts b/sample/src/hooks/useCheckoutEventHandlers.ts index 5101a37a..b9232115 100644 --- a/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/sample/src/hooks/useCheckoutEventHandlers.ts @@ -7,7 +7,6 @@ import type { CheckoutCompletedEvent, CheckoutException, PixelEvent, - RenderState, } from '@shopify/checkout-sheet-kit'; import {Linking} from 'react-native'; @@ -15,7 +14,7 @@ interface EventHandlers { onFail?: (error: CheckoutException) => void; onComplete?: (event: CheckoutCompletedEvent) => void; onCancel?: () => void; - onRenderStateChange?: (state: RenderState) => void; + onRenderStateChange?: (event: any) => void; onShouldRecoverFromError?: (error: {message: string}) => boolean; onWebPixelEvent?: (event: PixelEvent) => void; onClickLink?: (url: string) => void; @@ -37,8 +36,8 @@ export function useShopifyEventHandlers(name?: string): EventHandlers { onCancel: () => { log('onCancel'); }, - onRenderStateChange: state => { - log('onRenderStateChange', state); + onRenderStateChange: event => { + log('onRenderStateChange', event); }, onWebPixelEvent: event => { log('onWebPixelEvent', event.name); From f1ab9f9e8402e129ab34ce2b8e443e0c6455cbc7 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 26 Aug 2025 12:43:01 +0100 Subject: [PATCH 5/6] Handle runtime config validation errors, add tests --- .../@shopify/checkout-sheet-kit/src/index.ts | 30 ++++++++--- .../checkout-sheet-kit/tests/index.test.ts | 51 +++++++++++++++++-- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 7fb2fedf..4f14ac14 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -222,9 +222,9 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { return false; } - this.validateAcceleratedCheckoutsConfiguration(config); - try { + this.validateAcceleratedCheckoutsConfiguration(config); + const configured = await RNShopifyCheckoutSheetKit.configureAcceleratedCheckouts( config.storefrontDomain, @@ -238,8 +238,8 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { } catch (error) { // eslint-disable-next-line no-console console.error( - 'Failed to configured accelerated checkouts with config:', - config, + '[ShopifyCheckoutSheetKit] Failed to configure accelerated checkouts with', + error, ); return false; } @@ -288,10 +288,10 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { * Required Accelerated Checkouts configuration properties */ if (!acceleratedCheckouts?.storefrontDomain) { - throw new Error('storefrontDomain is required'); + throw new Error('`storefrontDomain` is required'); } if (!acceleratedCheckouts.storefrontAccessToken) { - throw new Error('storefrontAccessToken is required'); + throw new Error('`storefrontAccessToken` is required'); } /** @@ -299,7 +299,23 @@ class ShopifyCheckoutSheet implements ShopifyCheckoutSheetKit { */ if (acceleratedCheckouts.wallets?.applePay) { if (!acceleratedCheckouts.wallets.applePay.merchantIdentifier) { - throw new Error('wallets.applePay.merchantIdentifier is required'); + throw new Error('`wallets.applePay.merchantIdentifier` is required'); + } + + const expectedContactFields = Object.values(ApplePayContactField); + const hasInvalidContactFields = + Array.isArray(acceleratedCheckouts.wallets.applePay.contactFields) && + acceleratedCheckouts.wallets.applePay.contactFields.some( + value => + !expectedContactFields.includes( + value.toLowerCase() as ApplePayContactField, + ), + ); + + if (hasInvalidContactFields) { + throw new Error( + `'wallets.applePay.contactFields' contains unexpected values. Expected "${expectedContactFields.join(', ')}", received "${acceleratedCheckouts.wallets.applePay.contactFields}"`, + ); } } } diff --git a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts index 601db404..f9680a30 100644 --- a/modules/@shopify/checkout-sheet-kit/tests/index.test.ts +++ b/modules/@shopify/checkout-sheet-kit/tests/index.test.ts @@ -1,4 +1,5 @@ /* eslint-disable no-new */ +/* eslint-disable no-console */ import { LifecycleEventParseError, @@ -772,10 +773,15 @@ describe('ShopifyCheckoutSheetKit', () => { ...acceleratedConfig, storefrontDomain: '', }; + const expectedError = new Error('`storefrontDomain` is required'); await expect( instance.configureAcceleratedCheckouts(invalidConfig), - ).rejects.toThrow('storefrontDomain is required'); + ).resolves.toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[ShopifyCheckoutSheetKit] Failed to configure accelerated checkouts with', + expectedError, + ); }); it('validates required storefrontAccessToken', async () => { @@ -785,9 +791,15 @@ describe('ShopifyCheckoutSheetKit', () => { storefrontAccessToken: '', }; + const expectedError = new Error('`storefrontAccessToken` is required'); + await expect( instance.configureAcceleratedCheckouts(invalidConfig), - ).rejects.toThrow('storefrontAccessToken is required'); + ).resolves.toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[ShopifyCheckoutSheetKit] Failed to configure accelerated checkouts with', + expectedError, + ); }); it('validates required merchantIdentifier when Apple Pay is configured', async () => { @@ -802,9 +814,42 @@ describe('ShopifyCheckoutSheetKit', () => { }, }; + const expectedError = new Error( + '`wallets.applePay.merchantIdentifier` is required', + ); + await expect( instance.configureAcceleratedCheckouts(invalidConfig), - ).rejects.toThrow('wallets.applePay.merchantIdentifier is required'); + ).resolves.toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[ShopifyCheckoutSheetKit] Failed to configure accelerated checkouts with', + expectedError, + ); + }); + + it('validates required contactFields when Apple Pay is configured', async () => { + const instance = new ShopifyCheckoutSheet(); + const invalidConfig = { + ...acceleratedConfig, + wallets: { + applePay: { + contactFields: ['invalid'], + merchantIdentifier: 'merchant.test.com', + }, + }, + }; + + const expectedError = new Error( + `'wallets.applePay.contactFields' contains unexpected values. Expected "email, phone", received "invalid"`, + ); + + await expect( + instance.configureAcceleratedCheckouts(invalidConfig as any), + ).resolves.toBe(false); + expect(console.error).toHaveBeenCalledWith( + '[ShopifyCheckoutSheetKit] Failed to configure accelerated checkouts with', + expectedError, + ); }); it('does not throw when Apple Pay wallet is not configured', async () => { From 3d7ab737b7b514bcd1fc6d3405a1da52ea8c3d70 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 26 Aug 2025 12:46:42 +0100 Subject: [PATCH 6/6] Export RenderStateChangeEvent --- .../src/components/AcceleratedCheckoutButtons.tsx | 14 +++++++------- modules/@shopify/checkout-sheet-kit/src/index.ts | 2 ++ sample/src/hooks/useCheckoutEventHandlers.ts | 3 ++- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx index 399e8611..70ad01c6 100644 --- a/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx +++ b/modules/@shopify/checkout-sheet-kit/src/components/AcceleratedCheckoutButtons.tsx @@ -38,6 +38,12 @@ export enum RenderState { Unknown = 'unknown', } +export type RenderStateChangeEvent = + | {state: RenderState.Error; reason?: string} + | {state: RenderState.Loading} + | {state: RenderState.Rendered} + | {state: RenderState.Unknown}; + export enum ApplePayLabel { addMoney = 'addMoney', book = 'book', @@ -103,13 +109,7 @@ interface CommonAcceleratedCheckoutButtonsProps { * Called when the render state changes * States from SDK: loading, rendered, error */ - onRenderStateChange?: ( - event: - | {state: RenderState.Error; reason?: string} - | {state: RenderState.Loading} - | {state: RenderState.Rendered} - | {state: RenderState.Unknown}, - ) => void; + onRenderStateChange?: (event: RenderStateChangeEvent) => void; /** * Called when a web pixel event is triggered diff --git a/modules/@shopify/checkout-sheet-kit/src/index.ts b/modules/@shopify/checkout-sheet-kit/src/index.ts index 4f14ac14..8cd7ef1c 100644 --- a/modules/@shopify/checkout-sheet-kit/src/index.ts +++ b/modules/@shopify/checkout-sheet-kit/src/index.ts @@ -59,6 +59,7 @@ import {CheckoutErrorCode} from './errors.d'; import type {CheckoutCompletedEvent} from './events.d'; import type {CustomEvent, PixelEvent, StandardEvent} from './pixels.d'; import {ApplePayLabel} from './components/AcceleratedCheckoutButtons'; +import type {RenderStateChangeEvent} from './components/AcceleratedCheckoutButtons'; const RNShopifyCheckoutSheetKit = NativeModules.ShopifyCheckoutSheetKit; @@ -509,6 +510,7 @@ export type { PixelEvent, StandardEvent, AcceleratedCheckoutConfiguration, + RenderStateChangeEvent, }; // Components diff --git a/sample/src/hooks/useCheckoutEventHandlers.ts b/sample/src/hooks/useCheckoutEventHandlers.ts index b9232115..2511b5eb 100644 --- a/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/sample/src/hooks/useCheckoutEventHandlers.ts @@ -7,6 +7,7 @@ import type { CheckoutCompletedEvent, CheckoutException, PixelEvent, + RenderStateChangeEvent, } from '@shopify/checkout-sheet-kit'; import {Linking} from 'react-native'; @@ -14,7 +15,7 @@ interface EventHandlers { onFail?: (error: CheckoutException) => void; onComplete?: (event: CheckoutCompletedEvent) => void; onCancel?: () => void; - onRenderStateChange?: (event: any) => void; + onRenderStateChange?: (event: RenderStateChangeEvent) => void; onShouldRecoverFromError?: (error: {message: string}) => boolean; onWebPixelEvent?: (event: PixelEvent) => void; onClickLink?: (url: string) => void;