From a182814ece8941a9db875be0de73cb41178f5f51 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 3 Sep 2026 12:36:59 +0100 Subject: [PATCH 1/2] Support subscription line items in Swift sample --- .../Sources/Api/Queries/GetProducts.graphql | 9 ++ .../Sources/Api/StorefrontClient.swift | 15 +- .../Sources/App/CartManager.swift | 25 ++- .../Queries/GetProductsQuery.graphql.swift | 73 ++++++++- .../SellingPlanAllocation.graphql.swift | 15 ++ ...lingPlanAllocationConnection.graphql.swift | 13 ++ .../Schema/SchemaMetadata.graphql.swift | 4 +- .../Sources/Localizable.xcstrings | 8 + .../Sources/Scenes/ProductView.swift | 150 ++++++++++++++---- .../Api/StorefrontInputFactoryTests.swift | 13 ++ 10 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocation.graphql.swift create mode 100644 platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocationConnection.graphql.swift diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/Queries/GetProducts.graphql b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/Queries/GetProducts.graphql index 564eae8e4..d8b084b41 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/Queries/GetProducts.graphql +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/Queries/GetProducts.graphql @@ -6,6 +6,7 @@ query GetProducts($first: Int = 20, $country: CountryCode!, $language: LanguageC handle description vendor + requiresSellingPlan featuredImage { url } @@ -20,6 +21,14 @@ query GetProducts($first: Int = 20, $country: CountryCode!, $language: LanguageC id title availableForSale + sellingPlanAllocations(first: 10) { + nodes { + sellingPlan { + id + name + } + } + } price { amount currencyCode diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/StorefrontClient.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/StorefrontClient.swift index 191da463f..5d0aadb3f 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/StorefrontClient.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Api/StorefrontClient.swift @@ -53,9 +53,20 @@ class StorefrontInputFactory { case invariant(String) } - public func createCartInput(_ items: [String] = [], customerAccessToken: String? = nil) -> Storefront.CartInput { + func createCartLineInput(variantID: String, sellingPlanID: String? = nil) -> Storefront.CartLineInput { + Storefront.CartLineInput( + merchandiseId: variantID, + sellingPlanId: sellingPlanID.map(GraphQLNullable.some) ?? .none + ) + } + + public func createCartInput( + _ items: [String] = [], + sellingPlanID: String? = nil, + customerAccessToken: String? = nil + ) -> Storefront.CartInput { let lines: GraphQLNullable<[Storefront.CartLineInput]> = .some( - items.map { Storefront.CartLineInput(merchandiseId: $0) } + items.map { createCartLineInput(variantID: $0, sellingPlanID: sellingPlanID) } ) switch appConfiguration.buyerIdentityMode { diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartManager.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartManager.swift index 356d438e0..d09a02dbf 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartManager.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/App/CartManager.swift @@ -31,12 +31,20 @@ class CartManager: ObservableObject { // MARK: Cart Actions - func performCartLinesAdd(variant: String) async throws -> Storefront.CartFragment { + func performCartLinesAdd( + variant: String, + sellingPlanID: String? = nil + ) async throws -> Storefront.CartFragment { guard let cartId = cart?.id else { - return try await performCartCreate(items: [variant]) + return try await performCartCreate(items: [variant], sellingPlanID: sellingPlanID) } - let lines = [Storefront.CartLineInput(merchandiseId: variant)] + let lines = [ + StorefrontInputFactory.shared.createCartLineInput( + variantID: variant, + sellingPlanID: sellingPlanID + ) + ] let network = Network.shared let mutation = Storefront.CartLinesAddMutation( @@ -120,12 +128,19 @@ class CartManager: ObservableObject { } } - private func performCartCreate(items: [String] = []) async throws -> Storefront.CartFragment { + private func performCartCreate( + items: [String] = [], + sellingPlanID: String? = nil + ) async throws -> Storefront.CartFragment { var customerAccessToken: String? if CustomerAccountManager.shared.isAuthenticated { customerAccessToken = try? await CustomerAccountManager.shared.getValidAccessToken() } - let input = StorefrontInputFactory.shared.createCartInput(items, customerAccessToken: customerAccessToken) + let input = StorefrontInputFactory.shared.createCartInput( + items, + sellingPlanID: sellingPlanID, + customerAccessToken: customerAccessToken + ) let network = Network.shared let mutation = Storefront.CartCreateMutation( diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Operations/Queries/GetProductsQuery.graphql.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Operations/Queries/GetProductsQuery.graphql.swift index 718bb350c..bc4149eb4 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Operations/Queries/GetProductsQuery.graphql.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Operations/Queries/GetProductsQuery.graphql.swift @@ -9,7 +9,7 @@ extension Storefront { static let operationName: String = "GetProducts" static let operationDocument: ApolloAPI.OperationDocument = .init( definition: .init( - #"query GetProducts($first: Int = 20, $country: CountryCode!, $language: LanguageCode!) @inContext(country: $country, language: $language) { products(first: $first) { __typename nodes { __typename id title handle description vendor featuredImage { __typename url } collections(first: 1) { __typename nodes { __typename id title } } variants(first: 1) { __typename nodes { __typename id title availableForSale price { __typename amount currencyCode } } } } } }"# + #"query GetProducts($first: Int = 20, $country: CountryCode!, $language: LanguageCode!) @inContext(country: $country, language: $language) { products(first: $first) { __typename nodes { __typename id title handle description vendor requiresSellingPlan featuredImage { __typename url } collections(first: 1) { __typename nodes { __typename id title } } variants(first: 1) { __typename nodes { __typename id title availableForSale sellingPlanAllocations(first: 10) { __typename nodes { __typename sellingPlan { __typename id name } } } price { __typename amount currencyCode } } } } } }"# )) public var first: GraphQLNullable @@ -83,6 +83,7 @@ extension Storefront { .field("handle", String.self), .field("description", String.self), .field("vendor", String.self), + .field("requiresSellingPlan", Bool.self), .field("featuredImage", FeaturedImage?.self), .field("collections", Collections.self, arguments: ["first": 1]), .field("variants", Variants.self, arguments: ["first": 1]), @@ -104,6 +105,8 @@ extension Storefront { var description: String { __data["description"] } /// The name of the product's vendor. var vendor: String { __data["vendor"] } + /// Whether the product can only be purchased with a [selling plan](/docs/apps/build/purchase-options/subscriptions/selling-plans). Products that are sold on subscription (`requiresSellingPlan: true`) can be updated only for online stores. If you update a product to be subscription-only (`requiresSellingPlan:false`), then the product is unpublished from all channels, except the online store. + var requiresSellingPlan: Bool { __data["requiresSellingPlan"] } /// The featured image for the product. /// /// This field is functionally equivalent to `images(first: 1)`. @@ -214,6 +217,7 @@ extension Storefront { .field("id", Storefront.ID.self), .field("title", String.self), .field("availableForSale", Bool.self), + .field("sellingPlanAllocations", SellingPlanAllocations.self, arguments: ["first": 10]), .field("price", Price.self), ] } static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ @@ -226,9 +230,74 @@ extension Storefront { var title: String { __data["title"] } /// Indicates if the product variant is available for sale. var availableForSale: Bool { __data["availableForSale"] } + /// Represents an association between a variant and a selling plan. Selling plan allocations describe which selling plans are available for each variant, and what their impact is on pricing. + var sellingPlanAllocations: SellingPlanAllocations { __data["sellingPlanAllocations"] } /// The product variant’s price. var price: Price { __data["price"] } + /// Products.Node.Variants.Node.SellingPlanAllocations + /// + /// Parent Type: `SellingPlanAllocationConnection` + nonisolated struct SellingPlanAllocations: Storefront.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { Storefront.Objects.SellingPlanAllocationConnection } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("nodes", [Node].self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetProductsQuery.Data.Products.Node.Variants.Node.SellingPlanAllocations.self + ] } + + /// A list of the nodes contained in SellingPlanAllocationEdge. + var nodes: [Node] { __data["nodes"] } + + /// Products.Node.Variants.Node.SellingPlanAllocations.Node + /// + /// Parent Type: `SellingPlanAllocation` + nonisolated struct Node: Storefront.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { Storefront.Objects.SellingPlanAllocation } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("sellingPlan", SellingPlan.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetProductsQuery.Data.Products.Node.Variants.Node.SellingPlanAllocations.Node.self + ] } + + /// A representation of how products and variants can be sold and purchased. For example, an individual selling plan could be '6 weeks of prepaid granola, delivered weekly'. + var sellingPlan: SellingPlan { __data["sellingPlan"] } + + /// Products.Node.Variants.Node.SellingPlanAllocations.Node.SellingPlan + /// + /// Parent Type: `SellingPlan` + nonisolated struct SellingPlan: Storefront.SelectionSet { + let __data: DataDict + init(_dataDict: DataDict) { __data = _dataDict } + + static var __parentType: any ApolloAPI.ParentType { Storefront.Objects.SellingPlan } + static var __selections: [ApolloAPI.Selection] { [ + .field("__typename", String.self), + .field("id", Storefront.ID.self), + .field("name", String.self), + ] } + static var __fulfilledFragments: [any ApolloAPI.SelectionSet.Type] { [ + GetProductsQuery.Data.Products.Node.Variants.Node.SellingPlanAllocations.Node.SellingPlan.self + ] } + + /// A globally-unique ID. + var id: Storefront.ID { __data["id"] } + /// The name of the selling plan. For example, '6 weeks of prepaid granola, delivered weekly'. + var name: String { __data["name"] } + } + } + } + /// Products.Node.Variants.Node.Price /// /// Parent Type: `MoneyV2` @@ -258,4 +327,4 @@ extension Storefront { } } -} \ No newline at end of file +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocation.graphql.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocation.graphql.swift new file mode 100644 index 000000000..a0a9ce9b2 --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocation.graphql.swift @@ -0,0 +1,15 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension Storefront.Objects { + /// Links a [`ProductVariant`](https://shopify.dev/docs/api/storefront/current/objects/ProductVariant) to a [`SellingPlan`](https://shopify.dev/docs/api/storefront/current/objects/SellingPlan), providing the pricing details for that specific combination. Each allocation includes the checkout charge amount, any remaining balance due for the purchase, and up to two price adjustments that show how the selling plan affects the variant's price. + /// + /// Selling plan allocations are available on product variants and [cart lines](https://shopify.dev/docs/api/storefront/current/objects/CartLine), enabling storefronts to display information such as subscription or purchase option pricing before and during checkout. + static let SellingPlanAllocation = ApolloAPI.Object( + typename: "SellingPlanAllocation", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocationConnection.graphql.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocationConnection.graphql.swift new file mode 100644 index 000000000..e6cb4701d --- /dev/null +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/Objects/SellingPlanAllocationConnection.graphql.swift @@ -0,0 +1,13 @@ +// @generated +// This file was automatically generated and should not be edited. + +import ApolloAPI + +extension Storefront.Objects { + /// An auto-generated type for paginating through multiple SellingPlanAllocations. + static let SellingPlanAllocationConnection = ApolloAPI.Object( + typename: "SellingPlanAllocationConnection", + implementedInterfaces: [], + keyFields: nil + ) +} \ No newline at end of file diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/SchemaMetadata.graphql.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/SchemaMetadata.graphql.swift index dc2272161..327e1030a 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/SchemaMetadata.graphql.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Generated/Schema/SchemaMetadata.graphql.swift @@ -81,6 +81,8 @@ extension Storefront { "QueryRoot": Storefront.Objects.QueryRoot, "SearchQuerySuggestion": Storefront.Objects.SearchQuerySuggestion, "SellingPlan": Storefront.Objects.SellingPlan, + "SellingPlanAllocation": Storefront.Objects.SellingPlanAllocation, + "SellingPlanAllocationConnection": Storefront.Objects.SellingPlanAllocationConnection, "Shop": Storefront.Objects.Shop, "ShopPayInstallmentsFinancingPlan": Storefront.Objects.ShopPayInstallmentsFinancingPlan, "ShopPayInstallmentsFinancingPlanTerm": Storefront.Objects.ShopPayInstallmentsFinancingPlanTerm, @@ -102,4 +104,4 @@ extension Storefront { nonisolated enum Interfaces {} nonisolated enum Unions {} -} \ No newline at end of file +} diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings index ab6118e82..9025e4977 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Localizable.xcstrings @@ -62,6 +62,10 @@ "OK" : { "comment" : "Default action" }, + "One-time purchase" : { + "comment" : "A label for a purchase option that allows a user to purchase a product only once.", + "isCommentAutoGenerated" : true + }, "Populates the Cart Buyer Identity with values from Storefront.xcconfig" : { "comment" : "A description of how the \"Hardcoded\" buyer identity mode populates the cart buyer identity.", "isCommentAutoGenerated" : true @@ -70,6 +74,10 @@ "comment" : "A description of the \"Prefills buyer identity at checkout\" setting in the Settings view.", "isCommentAutoGenerated" : true }, + "Purchase option" : { + "comment" : "A label displayed above a picker for selecting a purchase option.", + "isCommentAutoGenerated" : true + }, "Sample app version" : { }, diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift index 57d828c3e..8c2837f51 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift @@ -16,12 +16,20 @@ struct ProductView: View { @State private var showingCart = false @State private var descriptionExpanded: Bool = false @State private var addedToCart: Bool = false + @State private var selectedSellingPlanID: String? + @State private var addToCartError: String? @AppStorage(AppStorageKeys.applePayStyle.rawValue) var applePayStyle: ApplePayStyleOption = .automatic init(product: Product) { _product = State(initialValue: product) + + let variant = product.variants.nodes.first + let requiredSellingPlanID = product.requiresSellingPlan + ? variant?.sellingPlanAllocations.nodes.first?.sellingPlan.id + : nil + _selectedSellingPlanID = State(initialValue: requiredSellingPlanID) } // MARK: Body @@ -93,6 +101,46 @@ struct ProductView: View { if let variant = product.variants.nodes.first { VStack(spacing: DesignSystem.buttonSpacing) { + if !variant.sellingPlanAllocations.nodes.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Purchase option") + .font(.subheadline) + .fontWeight(.semibold) + + Picker("Purchase option", selection: $selectedSellingPlanID) { + if !product.requiresSellingPlan { + Text("One-time purchase") + .tag(nil as String?) + } + + ForEach( + variant.sellingPlanAllocations.nodes, + id: \.sellingPlan.id + ) { allocation in + Text(allocation.sellingPlan.name) + .tag(allocation.sellingPlan.id as String?) + } + } + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let errorMessage = purchaseErrorMessage(for: variant) { + Label { + Text(errorMessage) + .frame(maxWidth: .infinity, alignment: .leading) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.subheadline) + .foregroundStyle(.red) + .padding(12) + .background(Color.red.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: DesignSystem.cornerRadius)) + .accessibilityElement(children: .combine) + } + Button(action: addToCart) { HStack { Text(loading ? "Adding..." : (addedToCart ? "Added" : "Add to Cart")) @@ -110,33 +158,11 @@ struct ProductView: View { .background(addedToCart ? Color(ColorPalette.successColor) : Color(ColorPalette.primaryColor)) .foregroundStyle(.white) .cornerRadius(DesignSystem.cornerRadius) - .disabled(!variant.availableForSale || loading) + .disabled(!canPurchase(variant) || loading) - if variant.availableForSale { + if canPurchase(variant), selectedSellingPlanID == nil { if #available(iOS 16, *) { - AcceleratedCheckoutButtons(variantID: variant.id, quantity: 1) - .wallets([.applePay]) - .applePayButtonStyle(applePayStyle.style) - .onFail { error in - print("[AcceleratedCheckout] Failed: \(error)") - } - .onDismiss { - print("[AcceleratedCheckout] Dismissed") - } - .environment( - \.shopifyAcceleratedCheckoutsConfiguration, - ShopifyAcceleratedCheckouts.Configuration( - storefrontDomain: InfoDictionary.shared.domain, - storefrontAccessToken: InfoDictionary.shared.accessToken - ) - ) - .environment( - \.shopifyApplePayConfiguration, - ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: InfoDictionary.shared.merchantIdentifier, - contactFields: [.email, .phone] - ) - ) + acceleratedCheckoutButton(for: variant) } } }.padding([.leading, .trailing], 15) @@ -159,23 +185,79 @@ struct ProductView: View { return MoneyV2(amount: variant.price.amount, currencyCode: variant.price.currencyCode).formattedString() ?? "" } + private func canPurchase(_ variant: Product.Variants.Node) -> Bool { + variant.availableForSale && (!product.requiresSellingPlan || selectedSellingPlanID != nil) + } + + private func purchaseErrorMessage(for _: Product.Variants.Node) -> String? { + if let addToCartError { + return addToCartError + } + if product.requiresSellingPlan, selectedSellingPlanID == nil { + return "This subscription doesn't have an available purchase option." + } + return nil + } + + @available(iOS 16, *) + private func acceleratedCheckoutButton( + for variant: Product.Variants.Node + ) -> some View { + AcceleratedCheckoutButtons(variantID: variant.id, quantity: 1) + .wallets([.applePay]) + .applePayButtonStyle(applePayStyle.style) + .onFail { error in + addToCartError = "We couldn't start accelerated checkout. Please try again." + print("[AcceleratedCheckout] Failed: \(error)") + } + .onDismiss { + print("[AcceleratedCheckout] Dismissed") + } + .environment( + \.shopifyAcceleratedCheckoutsConfiguration, + ShopifyAcceleratedCheckouts.Configuration( + storefrontDomain: InfoDictionary.shared.domain, + storefrontAccessToken: InfoDictionary.shared.accessToken + ) + ) + .environment( + \.shopifyApplePayConfiguration, + ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: InfoDictionary.shared.merchantIdentifier, + contactFields: [.email, .phone] + ) + ) + } + private func addToCart() { _Concurrency.Task { guard let variant = product.variants.nodes.first else { return } loading = true + addToCartError = nil + defer { loading = false } let start = Date() - _ = try await CartManager.shared.performCartLinesAdd(variant: variant.id) - - let diff = Date().timeIntervalSince(start) - let message = "Added item to cart in \(String(format: "%.0f", diff * 1000))ms" - ShopifyCheckoutKit.configuration.logger.log(message) - loading = false - addedToCart = true - - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + do { + _ = try await CartManager.shared.performCartLinesAdd( + variant: variant.id, + sellingPlanID: selectedSellingPlanID + ) + + let diff = Date().timeIntervalSince(start) + let message = "Added item to cart in \(String(format: "%.0f", diff * 1000))ms" + ShopifyCheckoutKit.configuration.logger.log(message) + addedToCart = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + addedToCart = false + } + } catch { addedToCart = false + addToCartError = "We couldn't add this item to your cart. Please try again." + ShopifyCheckoutKit.configuration.logger.log( + "Failed to add item to cart: \(error.localizedDescription)" + ) } } } diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Api/StorefrontInputFactoryTests.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Api/StorefrontInputFactoryTests.swift index c8906d1f3..ed2c502ec 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Api/StorefrontInputFactoryTests.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemoTests/Api/StorefrontInputFactoryTests.swift @@ -3,6 +3,19 @@ import XCTest @MainActor final class StorefrontInputFactoryTests: XCTestCase { + func testCartLineIncludesSellingPlan() { + let input = StorefrontInputFactory.shared.createCartLineInput( + variantID: "gid://shopify/ProductVariant/1", + sellingPlanID: "gid://shopify/SellingPlan/2" + ) + + XCTAssertEqual(input.merchandiseId, "gid://shopify/ProductVariant/1") + guard case let .some(sellingPlanID) = input.sellingPlanId else { + return XCTFail("Expected a selling plan ID") + } + XCTAssertEqual(sellingPlanID, "gid://shopify/SellingPlan/2") + } + func testHardcodedCartUsesASelectedReusableDeliveryAddress() { let originalBuyerIdentityMode = appConfiguration.buyerIdentityMode defer { appConfiguration.buyerIdentityMode = originalBuyerIdentityMode } From b8dd26274d5755cb0acd9f07f7e258e79ccff3e8 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Thu, 3 Sep 2026 12:48:27 +0100 Subject: [PATCH 2/2] Prototype selling plans in accelerated checkout --- .../Sources/Scenes/ProductView.swift | 50 ++++++++++--------- .../Internal/Models/CheckoutIdentifier.swift | 40 ++++++++++++--- .../StorefrontAPI+Mutations.swift | 10 +++- .../StorefrontAPI/StorefrontAPI.swift | 4 +- .../Wallets/AcceleratedCheckoutButtons.swift | 21 ++++++++ .../Wallets/WalletController.swift | 9 ++++ .../Models/CheckoutIdentifierTests.swift | 46 +++++++++++++++++ .../StorefrontAPIMutationsTests.swift | 9 +++- .../TestHelpers.swift | 20 ++++++-- .../Wallets/WalletControllerTests.swift | 25 ++++++++++ .../api/ShopifyAcceleratedCheckouts.json | 48 ++++++++++++++++++ 11 files changed, 242 insertions(+), 40 deletions(-) diff --git a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift index 8c2837f51..1c143d804 100644 --- a/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift +++ b/platforms/swift/Samples/CheckoutKitSwiftDemo/CheckoutKitSwiftDemo/Sources/Scenes/ProductView.swift @@ -160,7 +160,7 @@ struct ProductView: View { .cornerRadius(DesignSystem.cornerRadius) .disabled(!canPurchase(variant) || loading) - if canPurchase(variant), selectedSellingPlanID == nil { + if canPurchase(variant) { if #available(iOS 16, *) { acceleratedCheckoutButton(for: variant) } @@ -203,30 +203,34 @@ struct ProductView: View { private func acceleratedCheckoutButton( for variant: Product.Variants.Node ) -> some View { - AcceleratedCheckoutButtons(variantID: variant.id, quantity: 1) - .wallets([.applePay]) - .applePayButtonStyle(applePayStyle.style) - .onFail { error in - addToCartError = "We couldn't start accelerated checkout. Please try again." - print("[AcceleratedCheckout] Failed: \(error)") - } - .onDismiss { - print("[AcceleratedCheckout] Dismissed") - } - .environment( - \.shopifyAcceleratedCheckoutsConfiguration, - ShopifyAcceleratedCheckouts.Configuration( - storefrontDomain: InfoDictionary.shared.domain, - storefrontAccessToken: InfoDictionary.shared.accessToken - ) + AcceleratedCheckoutButtons( + variantID: variant.id, + quantity: 1, + sellingPlanID: selectedSellingPlanID + ) + .wallets([.applePay]) + .applePayButtonStyle(applePayStyle.style) + .onFail { error in + addToCartError = "We couldn't start accelerated checkout. Please try again." + print("[AcceleratedCheckout] Failed: \(error)") + } + .onDismiss { + print("[AcceleratedCheckout] Dismissed") + } + .environment( + \.shopifyAcceleratedCheckoutsConfiguration, + ShopifyAcceleratedCheckouts.Configuration( + storefrontDomain: InfoDictionary.shared.domain, + storefrontAccessToken: InfoDictionary.shared.accessToken ) - .environment( - \.shopifyApplePayConfiguration, - ShopifyAcceleratedCheckouts.ApplePayConfiguration( - merchantIdentifier: InfoDictionary.shared.merchantIdentifier, - contactFields: [.email, .phone] - ) + ) + .environment( + \.shopifyApplePayConfiguration, + ShopifyAcceleratedCheckouts.ApplePayConfiguration( + merchantIdentifier: InfoDictionary.shared.merchantIdentifier, + contactFields: [.email, .phone] ) + ) } private func addToCart() { diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/Models/CheckoutIdentifier.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/Models/CheckoutIdentifier.swift index 25e2d5dcb..5a5f43ab8 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/Models/CheckoutIdentifier.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/Models/CheckoutIdentifier.swift @@ -3,17 +3,22 @@ /// Type of identifier used for checkout enum CheckoutIdentifier { case variant(variantID: String, quantity: Int) + case subscriptionVariant(variantID: String, quantity: Int, sellingPlanID: String) case cart(cartID: String) case invariant(reason: String) var prefix: String { switch self { case .cart: "gid://Shopify/Cart/" - case .variant: "gid://Shopify/ProductVariant/" + case .variant, .subscriptionVariant: "gid://Shopify/ProductVariant/" default: "invariant" } } + private var sellingPlanPrefix: String { + "gid://Shopify/SellingPlan/" + } + /// Extracts the final portion of the cartID or variantID /// /// Example "gid://shopify/Cart/Z2NwLXVzLWV4YW1wbGU6MDEyMzQ1Njc4OTAxMjM0NTY3ODkw?key=examplekey1234567890" @@ -26,6 +31,8 @@ enum CheckoutIdentifier { return cartID.components(separatedBy: "/").last ?? "" case let .variant(variantID, _): return variantID.components(separatedBy: "/").last ?? "" + case let .subscriptionVariant(variantID, _, _): + return variantID.components(separatedBy: "/").last ?? "" case .invariant: return "" } @@ -53,16 +60,17 @@ enum CheckoutIdentifier { return self case let .variant(variantID, quantity): - guard variantID.lowercased().hasPrefix(prefix.lowercased()) else { - return .invariant( - reason: - "[invariant_violation] Invalid 'variantID' format. Expected to start with '\(prefix)', received: '\(variantID)'" - ) + return validateVariant(variantID: variantID, quantity: quantity) + + case let .subscriptionVariant(variantID, quantity, sellingPlanID): + let validatedVariant = validateVariant(variantID: variantID, quantity: quantity) + if case .invariant = validatedVariant { + return validatedVariant } - guard quantity > 0 else { + guard sellingPlanID.lowercased().hasPrefix(sellingPlanPrefix.lowercased()) else { return .invariant( reason: - "[invariant_violation] Quantity must be greater than 0, received: \(quantity)" + "[invariant_violation] Invalid 'sellingPlanID' format. Expected to start with '\(sellingPlanPrefix)', received: '\(sellingPlanID)'" ) } return self @@ -70,4 +78,20 @@ enum CheckoutIdentifier { default: return self } } + + private func validateVariant(variantID: String, quantity: Int) -> CheckoutIdentifier { + guard variantID.lowercased().hasPrefix(prefix.lowercased()) else { + return .invariant( + reason: + "[invariant_violation] Invalid 'variantID' format. Expected to start with '\(prefix)', received: '\(variantID)'" + ) + } + guard quantity > 0 else { + return .invariant( + reason: + "[invariant_violation] Quantity must be greater than 0, received: \(quantity)" + ) + } + return self + } } diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift index 1d576efd0..52d57c88b 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI+Mutations.swift @@ -7,14 +7,22 @@ extension StorefrontAPI { /// Create a new cart /// - Parameters: /// - items: Array of product variant IDs to add to the cart + /// - sellingPlanID: Optional selling plan ID to apply to each cart line /// - customer: Optional customer information to associate with the cart /// - Returns: The created cart func cartCreate( with items: [GraphQLScalars.ID] = [], + sellingPlanID: GraphQLScalars.ID? = nil, customer: ShopifyAcceleratedCheckouts.Customer? = nil ) async throws -> Cart { var input: [String: Any] = [ - "lines": items.map { ["merchandiseId": $0.rawValue] } + "lines": items.map { item in + var line = ["merchandiseId": item.rawValue] + if let sellingPlanID { + line["sellingPlanId"] = sellingPlanID.rawValue + } + return line + } ] if let customer { diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI.swift index 4d48720b3..132b296f4 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Internal/StorefrontAPI/StorefrontAPI.swift @@ -42,7 +42,9 @@ protocol StorefrontAPIProtocol: Sendable { // MARK: - Mutation Methods @discardableResult func cartCreate( - with items: [GraphQLScalars.ID], customer: ShopifyAcceleratedCheckouts.Customer? + with items: [GraphQLScalars.ID], + sellingPlanID: GraphQLScalars.ID?, + customer: ShopifyAcceleratedCheckouts.Customer? ) async throws -> StorefrontAPI.Cart @discardableResult func cartBuyerIdentityUpdate( diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift index a6417a42b..b4fab5c68 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/AcceleratedCheckoutButtons.swift @@ -60,6 +60,27 @@ public struct AcceleratedCheckoutButtons: View { } } + /// Initializes accelerated checkout buttons with a subscription variant ID + /// - Parameters: + /// - variantID: The variant ID to checkout (must start with gid://shopify/ProductVariant/) + /// - quantity: The quantity of the variant to checkout + /// - sellingPlanID: The optional selling plan ID to apply (must start with gid://shopify/SellingPlan/) + public init(variantID: String, quantity: Int, sellingPlanID: String?) { + identifier = if let sellingPlanID { + CheckoutIdentifier.subscriptionVariant( + variantID: variantID, + quantity: quantity, + sellingPlanID: sellingPlanID + ).parse() + } else { + CheckoutIdentifier.variant(variantID: variantID, quantity: quantity).parse() + } + if case let .invariant(reason) = identifier { + _currentRenderState = State(initialValue: .error(reason: reason)) + ShopifyAcceleratedCheckouts.logger.error(reason) + } + } + public var body: some View { VStack { if let shopSettings { diff --git a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift index 128d7180d..4156de3ac 100644 --- a/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift +++ b/platforms/swift/Sources/ShopifyAcceleratedCheckouts/Wallets/WalletController.swift @@ -27,6 +27,15 @@ class WalletController: ObservableObject { let items = Array(repeating: GraphQLScalars.ID(id), count: quantity) return try await storefront.cartCreate( with: items, + sellingPlanID: nil, + customer: configuration.customer + ) + + case let .subscriptionVariant(id, quantity, sellingPlanID): + let items = Array(repeating: GraphQLScalars.ID(id), count: quantity) + return try await storefront.cartCreate( + with: items, + sellingPlanID: GraphQLScalars.ID(sellingPlanID), customer: configuration.customer ) diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/Models/CheckoutIdentifierTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/Models/CheckoutIdentifierTests.swift index 68afadc79..3c9d2b31a 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/Models/CheckoutIdentifierTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/Models/CheckoutIdentifierTests.swift @@ -131,6 +131,21 @@ class CheckoutIdentifierTests: XCTestCase { } } + func test_subscriptionVariantInit_preservesIdentifiersAndQuantity() { + let identifier = CheckoutIdentifier.subscriptionVariant( + variantID: "gid://shopify/ProductVariant/test-id", + quantity: 2, + sellingPlanID: "gid://shopify/SellingPlan/test-id" + ) + + guard case let .subscriptionVariant(variantID, quantity, sellingPlanID) = identifier else { + return XCTFail("Expected subscriptionVariant case, got \(identifier)") + } + XCTAssertEqual(variantID, "gid://shopify/ProductVariant/test-id") + XCTAssertEqual(quantity, 2) + XCTAssertEqual(sellingPlanID, "gid://shopify/SellingPlan/test-id") + } + func test_invariantInit_whenReasonProvided_createsInvariantCase() { let reason = "Test error reason" let identifier = CheckoutIdentifier.invariant(reason: reason) @@ -260,6 +275,37 @@ class CheckoutIdentifierTests: XCTestCase { } } + func test_parse_whenSubscriptionVariantIsValid_returnsSelf() { + let identifier = CheckoutIdentifier.subscriptionVariant( + variantID: "gid://shopify/ProductVariant/test-id", + quantity: 2, + sellingPlanID: "gid://shopify/SellingPlan/test-id" + ) + + guard case let .subscriptionVariant(variantID, quantity, sellingPlanID) = identifier.parse() else { + return XCTFail("Expected valid subscription variant") + } + XCTAssertEqual(variantID, "gid://shopify/ProductVariant/test-id") + XCTAssertEqual(quantity, 2) + XCTAssertEqual(sellingPlanID, "gid://shopify/SellingPlan/test-id") + } + + func test_parse_whenSellingPlanIDIsInvalid_returnsInvariantWithReason() { + let identifier = CheckoutIdentifier.subscriptionVariant( + variantID: "gid://shopify/ProductVariant/test-id", + quantity: 1, + sellingPlanID: "invalid-selling-plan-id" + ) + + guard case let .invariant(reason) = identifier.parse() else { + return XCTFail("Expected invalid selling plan ID to return an invariant") + } + XCTAssertEqual( + reason, + "[invariant_violation] Invalid 'sellingPlanID' format. Expected to start with 'gid://Shopify/SellingPlan/', received: 'invalid-selling-plan-id'" + ) + } + func test_parse_whenVariantValidQuantity_returnsSelf() { let validVariantID = "gid://shopify/ProductVariant/test-id" for quantity in validQuantities() { diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift index 1383ed0c9..2533355c2 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Internal/StorefrontAPI/StorefrontAPIMutationsTests.swift @@ -199,7 +199,7 @@ final class StorefrontAPIMutationsTests: XCTestCase { ) } - func testCartCreateRequestValidation() async throws { + func testCartCreateRequestValidationWithSellingPlan() async throws { let json = """ { "data": { @@ -231,7 +231,10 @@ final class StorefrontAPIMutationsTests: XCTestCase { GraphQLScalars.ID("gid://shopify/ProductVariant/1"), GraphQLScalars.ID("gid://shopify/ProductVariant/2") ] - _ = try await storefrontAPI.cartCreate(with: variantIds) + _ = try await storefrontAPI.cartCreate( + with: variantIds, + sellingPlanID: GraphQLScalars.ID("gid://shopify/SellingPlan/1") + ) XCTAssertNotNil(MockURLProtocol.capturedRequestBody) @@ -247,7 +250,9 @@ final class StorefrontAPIMutationsTests: XCTestCase { XCTAssertEqual(lines?.count, 2) XCTAssertEqual(lines?[0]["merchandiseId"] as? String, "gid://shopify/ProductVariant/1") + XCTAssertEqual(lines?[0]["sellingPlanId"] as? String, "gid://shopify/SellingPlan/1") XCTAssertEqual(lines?[1]["merchandiseId"] as? String, "gid://shopify/ProductVariant/2") + XCTAssertEqual(lines?[1]["sellingPlanId"] as? String, "gid://shopify/SellingPlan/1") } func testCartCreateWithBuyerIdentityData() async throws { diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift index e33406614..e0772f86e 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/TestHelpers.swift @@ -285,11 +285,13 @@ class MockStorefrontAPI: StorefrontAPIProtocol, @unchecked Sendable { fatalError("shop() not implemented in test. Override this method in your test class.") } - func cartCreate(with _: [GraphQLScalars.ID], customer _: ShopifyAcceleratedCheckouts.Customer?) - async throws -> StorefrontAPI.Cart - { + func cartCreate( + with _: [GraphQLScalars.ID], + sellingPlanID _: GraphQLScalars.ID?, + customer _: ShopifyAcceleratedCheckouts.Customer? + ) async throws -> StorefrontAPI.Cart { fatalError( - "cartCreate(with:customer:) not implemented in test. Override this method in your test class." + "cartCreate(with:sellingPlanID:customer:) not implemented in test. Override this method in your test class." ) } @@ -372,7 +374,15 @@ class TestStorefrontAPI: MockStorefrontAPI, @unchecked Sendable { } var cartCreateResult: Result? - override func cartCreate(with _: [GraphQLScalars.ID], customer _: ShopifyAcceleratedCheckouts.Customer?) async throws -> StorefrontAPI.Cart { + var cartCreateItems: [GraphQLScalars.ID]? + var cartCreateSellingPlanID: GraphQLScalars.ID? + override func cartCreate( + with items: [GraphQLScalars.ID], + sellingPlanID: GraphQLScalars.ID?, + customer _: ShopifyAcceleratedCheckouts.Customer? + ) async throws -> StorefrontAPI.Cart { + cartCreateItems = items + cartCreateSellingPlanID = sellingPlanID guard let result = cartCreateResult else { fatalError("cartCreateResult not configured for TestStorefrontAPI") } diff --git a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/WalletControllerTests.swift b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/WalletControllerTests.swift index 448ad6405..a4256a741 100644 --- a/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/WalletControllerTests.swift +++ b/platforms/swift/Tests/ShopifyAcceleratedCheckoutsTests/Wallets/WalletControllerTests.swift @@ -102,6 +102,31 @@ final class WalletControllerTests: XCTestCase { let result = try await controller.fetchCartByCheckoutIdentifier() XCTAssertEqual(result.id, expectedCart.id) + XCTAssertNil(mockStorefront.cartCreateSellingPlanID) + } + + func test_fetchCartByCheckoutIdentifier_withSubscriptionVariant_forwardsSellingPlanID() async throws { + let expectedCart = StorefrontAPI.Cart.testCart + mockStorefront.cartCreateResult = .success(expectedCart) + + controller = MockWalletController( + identifier: .subscriptionVariant( + variantID: "gid://Shopify/ProductVariant/test-variant-id", + quantity: 2, + sellingPlanID: "gid://Shopify/SellingPlan/test-selling-plan-id" + ), + storefront: mockStorefront, + configuration: .testConfiguration + ) + + let result = try await controller.fetchCartByCheckoutIdentifier() + + XCTAssertEqual(result.id, expectedCart.id) + XCTAssertEqual(mockStorefront.cartCreateItems?.count, 2) + XCTAssertEqual( + mockStorefront.cartCreateSellingPlanID?.rawValue, + "gid://Shopify/SellingPlan/test-selling-plan-id" + ) } func test_fetchCartByCheckoutIdentifier_withVariantIdentifierZeroQuantity_shouldSucceed() async throws { diff --git a/platforms/swift/api/ShopifyAcceleratedCheckouts.json b/platforms/swift/api/ShopifyAcceleratedCheckouts.json index c3cd12ff1..c3ac2fc41 100644 --- a/platforms/swift/api/ShopifyAcceleratedCheckouts.json +++ b/platforms/swift/api/ShopifyAcceleratedCheckouts.json @@ -1855,6 +1855,54 @@ ], "init_kind": "Designated" }, + { + "kind": "Constructor", + "name": "init", + "printedName": "init(variantID:quantity:sellingPlanID:)", + "children": [ + { + "kind": "TypeNominal", + "name": "AcceleratedCheckoutButtons", + "printedName": "ShopifyAcceleratedCheckouts.AcceleratedCheckoutButtons", + "usr": "s:27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "Int", + "printedName": "Swift.Int", + "usr": "s:Si" + }, + { + "kind": "TypeNominal", + "name": "Optional", + "printedName": "Swift.String?", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "usr": "s:Sq" + } + ], + "declKind": "Constructor", + "usr": "s:27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV9variantID8quantity011sellingPlanG0ACSS_SiSSSgtcfc", + "mangledName": "$s27ShopifyAcceleratedCheckouts0B15CheckoutButtonsV9variantID8quantity011sellingPlanG0ACSS_SiSSSgtcfc", + "moduleName": "ShopifyAcceleratedCheckouts", + "declAttributes": [ + "Preconcurrency", + "Custom" + ], + "init_kind": "Designated" + }, { "kind": "Var", "name": "body",