Skip to content

Commit

Permalink
[Sample: MobileBuyIntegration] Handle Universal Links (#243)
Browse files Browse the repository at this point in the history
* [Sample: MobileBuyIntegration] Handle Universal Links

* Simplify state
  • Loading branch information
markmur authored Nov 13, 2024
1 parent e64d89a commit 2a403dd
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# Remove .pcm binaries from build\nfind \"${TARGET_BUILD_DIR}/${FULL_PRODUCT_NAME}\" -name \"*.pcm\" -type f -delete\n\n# Remove Swiftlint binaries from build\nfind \"${TARGET_BUILD_DIR}/${FULL_PRODUCT_NAME}\" -name \"SwiftLint*\" -type f -delete\n\n\n";
shellScript = "# Remove .pcm binaries from build\nfind \"${TARGET_BUILD_DIR}/${FULL_PRODUCT_NAME}\" -name \"*.pcm\" -type f -delete\n\n# Remove Swiftlint binaries from build\nfind \"${TARGET_BUILD_DIR}/${FULL_PRODUCT_NAME}\" -iname \"swiftlint*\" -type f -delete\n\n\n\n";
};
/* End PBXShellScriptBuildPhase section */

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ 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 ShopifyCheckoutSheetKit

public struct AppConfiguration {
public final class AppConfiguration: ObservableObject {
public var storefrontDomain: String = Bundle.main.infoDictionary?["StorefrontDomain"] as? String ?? ""

@Published public var universalLinks = UniversalLinks()

/// Prefill buyer information
public var useVaultedState: Bool = false
@Published public var useVaultedState: Bool = false

/// Logger to retain Web Pixel events
internal let webPixelsLogger = FileLogger("analytics.txt")
Expand All @@ -36,3 +41,23 @@ public var appConfiguration = AppConfiguration() {
CartManager.shared.resetCart()
}
}

public struct UniversalLinks {
public var checkout: Bool = true
public var cart: Bool = true
public var products: Bool = true

public var handleAllURLsInApp: Bool = true {
didSet {
if handleAllURLsInApp {
enableAllURLs()
}
}
}

private mutating func enableAllURLs() {
checkout = true
products = true
cart = true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
},
"App version" : {

},
"By default, the app will only handle the selections above and route everything else to Safari. Enabling the \"Handle all Universal Links\" setting will route all Universal Links to this app." : {

},
"Clear" : {

Expand All @@ -18,6 +21,18 @@
},
"Features" : {

},
"Handle all Universal Links" : {

},
"Handle Cart URLs" : {

},
"Handle Checkout URLs" : {

},
"Handle Product URLs" : {

},
"Logs" : {

Expand Down Expand Up @@ -66,6 +81,9 @@
},
"Theme" : {

},
"Universal Links" : {

},
"Version" : {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import ShopifyCheckoutSheetKit
class ProductViewController: UIViewController {

// MARK: Properties
private var handle: String?

@IBOutlet private var image: UIImageView!

Expand Down Expand Up @@ -55,8 +56,10 @@ class ProductViewController: UIViewController {

// MARK: Initializers

convenience init() {
convenience init(handle: String?) {
self.init(nibName: nil, bundle: nil)

self.handle = handle
}

// MARK: UIViewController Lifecycle
Expand All @@ -80,7 +83,11 @@ class ProductViewController: UIViewController {
}
}

reloadProduct()
if let handle = handle {
getProductByHandle(handle)
} else {
reloadProduct()
}
}

private func rerender() {
Expand All @@ -89,6 +96,7 @@ class ProductViewController: UIViewController {
$0.configuration?.showsActivityIndicator = self.loading
}
}
updateProductDetails()
}

private func setLoading(_ state: Bool) {
Expand All @@ -101,6 +109,13 @@ class ProductViewController: UIViewController {
}
}

private func setProduct(_ product: Storefront.Product?) {
if let product = product {
self.product = product
self.handle = product.handle
}
}

// MARK: Actions

@IBAction func addToCart() {
Expand All @@ -117,12 +132,49 @@ class ProductViewController: UIViewController {
}
}

public func getProductByHandle(_ handle: String) {
let context = Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion())
let query = Storefront.buildQuery(inContext: context) { $0
.products(first: 1, query: handle) { $0
.nodes { $0
.id()
.title()
.handle()
.description()
.vendor()
.featuredImage { $0
.url()
}
.variants(first: 1) { $0
.nodes { $0
.id()
.title()
.availableForSale()
.price { $0
.amount()
.currencyCode()
}
}
}
}
}
}

StorefrontClient.shared.execute(query: query) { [weak self] result in
self?.setLoading(false)
if case .success(let query) = result {
self?.setProduct(query.products.nodes.first)
}
}
}

@IBAction private func reloadProduct() {
let query = Storefront.buildQuery(inContext: Storefront.InContextDirective(country: Storefront.CountryCode.inferRegion())) { $0
.products(first: 250) { $0
.nodes { $0
.id()
.title()
.handle()
.description()
.vendor()
.featuredImage { $0
Expand All @@ -146,7 +198,7 @@ class ProductViewController: UIViewController {
StorefrontClient.shared.execute(query: query) { [weak self] result in
self?.setLoading(false)
if case .success(let query) = result {
self?.product = query.products.nodes.randomElement()
self?.setProduct(query.products.nodes.randomElement())
}
}
}
Expand Down
134 changes: 123 additions & 11 deletions Samples/MobileBuyIntegration/MobileBuyIntegration/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,34 +29,37 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {

var window: UIWindow?

var cartController: CartViewController?
var productController: ProductViewController?

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }

let tabBarController = UITabBarController()

/// Catalog
let catalogController = ProductViewController()
catalogController.tabBarItem.image = UIImage(systemName: "books.vertical")
catalogController.tabBarItem.title = "Browse"
catalogController.navigationItem.title = "Product details"
productController = ProductViewController()
productController?.tabBarItem.image = UIImage(systemName: "books.vertical")
productController?.tabBarItem.title = "Browse"
productController?.navigationItem.title = "Product details"

/// Cart
let cartController = CartViewController()
cartController.tabBarItem.image = UIImage(systemName: "cart")
cartController.tabBarItem.title = "Cart"
cartController.navigationItem.title = "Cart"
cartController = CartViewController()
cartController?.tabBarItem.image = UIImage(systemName: "cart")
cartController?.tabBarItem.title = "Cart"
cartController?.navigationItem.title = "Cart"

tabBarController.viewControllers = [
UINavigationController(
rootViewController: catalogController
rootViewController: productController!
),
UINavigationController(
rootViewController: cartController
rootViewController: cartController!
)
]

if #available(iOS 15.0, *) {
let settingsController = UIHostingController(rootView: SettingsView())
let settingsController = UIHostingController(rootView: SettingsView(appConfiguration: appConfiguration))
settingsController.tabBarItem.image = UIImage(systemName: "gearshape.2")
settingsController.tabBarItem.title = "Settings"

Expand All @@ -76,6 +79,74 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
self.window = window
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let incomingURL = userActivity.webpageURL,

/// Ensure URL host matches our Storefront domain
let host = incomingURL.host, host == appConfiguration.storefrontDomain
else {
return
}

handleUniversalLink(url: incomingURL)
}

func handleUniversalLink(url: URL) {
let storefrontUrl = StorefrontURL(from: url)

switch true {
/// Checkout URLs
case appConfiguration.universalLinks.checkout && storefrontUrl.isCheckout() && !storefrontUrl.isThankYouPage():
presentCheckout(url)
/// Cart URLs
case appConfiguration.universalLinks.cart && storefrontUrl.isCart():
navigateToCart()
/// Product URLs
case appConfiguration.universalLinks.products:
if let slug = storefrontUrl.getProductSlug() {
navigateToProduct(with: slug)
}
/// Open everything else in Safari
default:
UIApplication.shared.open(url)
}
}

private func presentCheckout(_ url: URL) {
if let viewController = cartController {
ShopifyCheckoutSheetKit.present(checkout: url, from: viewController, delegate: viewController)
}
}

private func getRootViewController() -> UINavigationController? {
return window?.rootViewController as? UINavigationController
}

private func getNavigationController(forTab index: Int) -> UINavigationController? {
guard let tabBarVC = window?.rootViewController as? UITabBarController else {
return nil
}
return tabBarVC.viewControllers?[index] as? UINavigationController
}

func navigateToCart() {
if let tabBarVC = window?.rootViewController as? UITabBarController {
tabBarVC.selectedIndex = 1
}
}

func navigateToProduct(with handle: String) {
if let pdp = self.productController {
pdp.getProductByHandle(handle)
}

if let tabBarVC = window?.rootViewController as? UITabBarController {
tabBarVC.selectedIndex = 0
}
}

@objc func colorSchemeChanged() {
window?.overrideUserInterfaceStyle = ShopifyCheckoutSheetKit.configuration.colorScheme.userInterfaceStyle
}
Expand All @@ -97,3 +168,44 @@ extension Configuration.ColorScheme {
}
}
}

public struct StorefrontURL {
public let url: URL

private let slug = "([\\w\\d_-]+)"

init(from url: URL) {
self.url = url
}

public func isThankYouPage() -> Bool {
return url.path.range(of: "/thank[-_]you", options: .regularExpression) != nil
}

public func isCheckout() -> Bool {
return url.path.contains("/checkout")
}

public func isCart() -> Bool {
return url.path.contains("/cart")
}

public func isCollection() -> Bool {
return url.path.range(of: "/collections/\(slug)", options: .regularExpression) != nil
}

public func isProduct() -> Bool {
return url.path.range(of: "/products/\(slug)", options: .regularExpression) != nil
}

public func getProductSlug() -> String? {
guard isProduct() else { return nil }

let pattern = "/products/([\\w_-]+)"
if let match = url.path.range(of: pattern, options: .regularExpression, range: nil, locale: nil) {
let slug = url.path[match].components(separatedBy: "/").last
return slug
}
return nil
}
}
Loading

0 comments on commit 2a403dd

Please sign in to comment.