diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index 6ab89a23c06..8875898f02c 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -39,6 +39,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .inAppPurchases: return buildConfig == .localDeveloper || buildConfig == .alpha + case .storeCreationMVP: + return buildConfig == .localDeveloper || buildConfig == .alpha default: return true } diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index 89859260f23..5c42371ed91 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -81,4 +81,8 @@ public enum FeatureFlag: Int { /// Enables In-app purchases for buying Hosted WooCommerce plans /// case inAppPurchases + + /// Store creation MVP. + /// + case storeCreationMVP } diff --git a/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift index 6e6902dc8ea..05598d71cab 100644 --- a/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift +++ b/WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift @@ -14,6 +14,7 @@ final class AuthenticatedWebViewController: UIViewController { let webView = WKWebView(frame: .zero) webView.translatesAutoresizingMaskIntoConstraints = false webView.navigationDelegate = self + webView.uiDelegate = self return webView }() @@ -156,3 +157,17 @@ extension AuthenticatedWebViewController: WKNavigationDelegate { progressBar.setProgress(0, animated: false) } } + +extension AuthenticatedWebViewController: WKUIDelegate { + func webView(_ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures) -> WKWebView? { + // Allows `target=_blank` links by opening them in the same view, otherwise tapping on these links is no-op. + // Reference: https://stackoverflow.com/a/25853806/9185596 + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + return nil + } +} diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift index 7ad1ec6f2d5..7d074241748 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift @@ -102,6 +102,9 @@ final class StorePickerViewController: UIViewController { } } + /// Create store button. + @IBOutlet private weak var createStoreButton: FancyAnimatedButton! + /// New To Woo button /// @IBOutlet var newToWooButton: UIButton! { @@ -157,9 +160,13 @@ final class StorePickerViewController: UIViewController { self?.restartAuthentication() } + @Published private var possibleSiteURLsFromStoreCreation: Set = [] + private var possibleSiteURLsFromStoreCreationSubscription: AnyCancellable? + private let appleIDCredentialChecker: AppleIDCredentialCheckerProtocol private let stores: StoresManager private let featureFlagService: FeatureFlagService + private let isStoreCreationEnabled: Bool init(configuration: StorePickerConfiguration, appleIDCredentialChecker: AppleIDCredentialCheckerProtocol = AppleIDCredentialChecker(), @@ -170,6 +177,7 @@ final class StorePickerViewController: UIViewController { self.stores = stores self.featureFlagService = featureFlagService self.viewModel = StorePickerViewModel(configuration: configuration) + self.isStoreCreationEnabled = featureFlagService.isFeatureFlagEnabled(.storeCreationMVP) super.init(nibName: Self.nibName, bundle: nil) } @@ -185,8 +193,10 @@ final class StorePickerViewController: UIViewController { setupMainView() setupAccountHeader() setupTableView() + setupCreateStoreButton() refreshResults() observeStateChange() + observeSiteURLsFromStoreCreation() switch configuration { case .login: @@ -276,6 +286,17 @@ private extension StorePickerViewController { } } + func setupCreateStoreButton() { + createStoreButton.isHidden = isStoreCreationEnabled == false + createStoreButton.isPrimary = false + createStoreButton.backgroundColor = .clear + createStoreButton.titleFont = StyleManager.actionButtonTitleFont + createStoreButton.setTitle(Localization.createStore, for: .normal) + createStoreButton.on(.touchUpInside) { [weak self] _ in + self?.createStoreButtonPressed() + } + } + func refreshResults() { viewModel.refreshSites(currentlySelectedSiteID: currentlySelectedSite?.siteID) viewModel.trackScreenView() @@ -296,6 +317,28 @@ private extension StorePickerViewController { func presentHelp() { ServiceLocator.authenticationManager.presentSupport(from: self, screen: .storePicker) } + + func observeSiteURLsFromStoreCreation() { + possibleSiteURLsFromStoreCreationSubscription = $possibleSiteURLsFromStoreCreation + .filter { $0.isEmpty == false } + .removeDuplicates() + // There are usually three URLs in the webview that return a site URL - two with `*.wordpress.com` and the other the final URL. + .debounce(for: .seconds(5), scheduler: DispatchQueue.main) + .asyncMap { [weak self] possibleSiteURLs -> Site? in + // Waits for 5 seconds before syncing sites every time. + try await Task.sleep(nanoseconds: 5_000_000_000) + return try await self?.syncSites(forSiteThatMatchesPossibleURLs: possibleSiteURLs) + } + // Retries 10 times with 5 seconds pause in between to wait for the newly created site to be available as a Jetpack site + // in the WPCOM `/me/sites` response. + .retry(10) + .replaceError(with: nil) + .receive(on: DispatchQueue.main) + .sink { [weak self] site in + guard let self, let site else { return } + self.continueWithSelectedSite(site: site) + } + } } @@ -540,7 +583,7 @@ extension StorePickerViewController: UIViewControllerTransitioningDelegate { // MARK: - Action Handlers // -extension StorePickerViewController { +private extension StorePickerViewController { /// Proceeds with the Login Flow. /// @@ -583,6 +626,67 @@ extension StorePickerViewController { @IBAction func secondaryActionWasPressed() { restartAuthentication() } + + func createStoreButtonPressed() { + // TODO-7879: analytics + + let viewModel = StoreCreationWebViewModel { [weak self] result in + self?.handleStoreCreationResult(result) + } + possibleSiteURLsFromStoreCreation = [] + let webViewController = AuthenticatedWebViewController(viewModel: viewModel) + webViewController.addCloseNavigationBarButton(target: self, action: #selector(handleStoreCreationCloseAction)) + let navigationController = WooNavigationController(rootViewController: webViewController) + // Disables interactive dismissal of the store creation modal. + navigationController.isModalInPresentation = true + present(navigationController, animated: true) + } + + @objc func handleStoreCreationCloseAction() { + // TODO-7879: show a confirmation alert before closing the store creation view + // TODO-7879: analytics + dismiss(animated: true) + } + + func handleStoreCreationResult(_ result: Result) { + switch result { + case .success(let siteURL): + // TODO-7879: analytics + + // There could be multiple site URLs from the completion URL in the webview, and only one + // of them matches the final site URL from WPCOM `/me/sites` endpoint. + possibleSiteURLsFromStoreCreation.insert(siteURL) + case .failure(let error): + // TODO-7879: analytics + DDLogError("Store creation error: \(error)") + } + } + + @MainActor + func syncSites(forSiteThatMatchesPossibleURLs possibleURLs: Set) async throws -> Site { + return try await withCheckedThrowingContinuation { [weak self] continuation in + viewModel.refreshSites(currentlySelectedSiteID: nil) { [weak self] in + guard let self else { return } + // The newly created site often has `isJetpackThePluginInstalled=false` initially, + // which results in a JCP site. + // In this case, we want to retry sites syncing. + guard let site = self.viewModel.site(thatMatchesPossibleURLs: possibleURLs) else { + return continuation.resume(throwing: StoreCreationError.newSiteUnavailable) + } + guard site.isJetpackConnected && site.isJetpackThePluginInstalled else { + return continuation.resume(throwing: StoreCreationError.newSiteIsNotJetpackSite) + } + continuation.resume(returning: site) + } + } + } + + func continueWithSelectedSite(site: Site) { + currentlySelectedSite = site + dismiss(animated: true) { [weak self] in + self?.checkRoleEligibility(for: site) + } + } } @@ -759,6 +863,8 @@ private extension StorePickerViewController { comment: "Button to input a site address in store picker when there are no stores found") static let newToWooCommerce = NSLocalizedString("New to WooCommerce?", comment: "Title of button on the site picker screen for users who are new to WooCommerce.") + static let createStore = NSLocalizedString("Create a new store", + comment: "Button to create a new store from the store picker") } } diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.xib b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.xib index b959c09c313..c46ef29dfb8 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.xib +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.xib @@ -1,9 +1,9 @@ - + - + @@ -11,6 +11,7 @@ + @@ -24,7 +25,7 @@ - + @@ -32,10 +33,10 @@ - + - + + diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift index d360af33654..65e98f2d912 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift @@ -48,11 +48,12 @@ final class StorePickerViewModel { ]) } - func refreshSites(currentlySelectedSiteID: Int64?) { + func refreshSites(currentlySelectedSiteID: Int64?, completion: (() -> Void)? = nil) { refetchSitesAndUpdateState() synchronizeSites(selectedSiteID: currentlySelectedSiteID) { [weak self] _ in self?.refetchSitesAndUpdateState() + completion?() } } @@ -159,6 +160,20 @@ extension StorePickerViewModel { } return resultsController.safeObject(at: indexPath) } + + /// Returns the site that matches the given URL. + /// + func site(thatMatchesPossibleURLs possibleURLs: Set) -> Site? { + guard resultsController.numberOfObjects > 0 else { + return nil + } + return resultsController.fetchedObjects.first(where: { site in + guard let siteURL = URL(string: site.url)?.host else { + return false + } + return possibleURLs.contains(siteURL) + }) + } } private extension StorePickerViewModel { diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationWebViewModel.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationWebViewModel.swift new file mode 100644 index 00000000000..866efedbc53 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationWebViewModel.swift @@ -0,0 +1,96 @@ +import Foundation +import WebKit + +/// View model used for the web view controller to create a store. +/// +final class StoreCreationWebViewModel: AuthenticatedWebViewModel { + // `AuthenticatedWebViewModel` protocol conformance. + let title = Localization.title + let initialURL: URL? = Constants.storeCreationURL + + private let completion: (Result) -> Void + + init(completion: @escaping (Result) -> Void) { + self.completion = completion + } + + func handleDismissal() { + // no-op: dismissal is handled in the close button in the navigation bar. + } + + func handleRedirect(for url: URL?) { + guard let path = url?.absoluteString else { + return + } + handleCompletionIfPossible(path) + } + + func decidePolicy(for navigationURL: URL) async -> WKNavigationActionPolicy { + handleCompletionIfPossible(navigationURL.absoluteString) + return .allow + } +} + +enum StoreCreationError: Error { + case noSiteURLInCompletionPath + case invalidCompletionPath + case newSiteUnavailable + case newSiteIsNotJetpackSite +} + +private extension StoreCreationWebViewModel { + func handleCompletionIfPossible(_ url: String) { + guard url.starts(with: Constants.completionURLPrefix) else { + return + } + do { + // A successful URL looks like `https://wordpress.com/checkout/thank-you/{{site_url}}/.*`. + // There is usually more than one URL requests like this, with different parameters. + let regex = try NSRegularExpression(pattern: "\(Constants.completionURLPrefix)" + #"(?[^/]+)"#, options: []) + let urlRange = NSRange(location: 0, length: url.count) + let matches = regex.matches(in: url, options: [], range: urlRange) + guard let match = matches.first else { + throw StoreCreationError.invalidCompletionPath + } + + let siteURL = siteURL(from: match, requestURL: url) + guard let siteURL = siteURL else { + throw StoreCreationError.noSiteURLInCompletionPath + } + // Running on the main thread is necessary if this method is triggered from `decidePolicy`. + DispatchQueue.main.async { [weak self] in + self?.handleSuccess(siteURL: siteURL) + } + } catch { + handleError(error) + } + } + + func handleSuccess(siteURL: String) { + completion(.success(siteURL)) + } + + func handleError(_ error: Error) { + completion(.failure(error)) + } + + /// Extracts the site URL substring matching the named capture group `siteURL` in the regex. + func siteURL(from match: NSTextCheckingResult, requestURL: String) -> String? { + let matchRange = match.range(withName: "siteURL") + guard let substringRange = Range(matchRange, in: requestURL) else { + return nil + } + return String(requestURL[substringRange]) + } +} + +private extension StoreCreationWebViewModel { + enum Constants { + static let storeCreationURL = WooConstants.URLs.storeCreation.asURL() + static let completionURLPrefix = "https://wordpress.com/checkout/thank-you/" + } + + enum Localization { + static let title = NSLocalizedString("Create a store", comment: "Title of the store creation web view.") + } +} diff --git a/WooCommerce/Classes/Extensions/Publisher+Concurrency.swift b/WooCommerce/Classes/Extensions/Publisher+Concurrency.swift new file mode 100644 index 00000000000..9de3b229a31 --- /dev/null +++ b/WooCommerce/Classes/Extensions/Publisher+Concurrency.swift @@ -0,0 +1,27 @@ +import Combine + +extension Publisher { + /// Transforms the publisher with an async throwable operator. + /// + /// Original implementation: + /// https://www.swiftbysundell.com/articles/calling-async-functions-within-a-combine-pipeline + /// + /// - Parameter transform: a function that transforms the upstream publisher asynchronously with the option to throw an error. + /// - Returns: a new publisher that is transformed by the given operator asynchronously. + func asyncMap(_ transform: @escaping (Output) async throws -> T) -> + Publishers.FlatMap, + Publishers.SetFailureType> { + flatMap { value in + Future { promise in + Task { + do { + let output = try await transform(value) + promise(.success(output)) + } catch { + promise(.failure(error)) + } + } + } + } + } +} diff --git a/WooCommerce/Classes/System/WooConstants.swift b/WooCommerce/Classes/System/WooConstants.swift index a7c97e245cb..abcd880189b 100644 --- a/WooCommerce/Classes/System/WooConstants.swift +++ b/WooCommerce/Classes/System/WooConstants.swift @@ -229,6 +229,9 @@ extension WooConstants { case wcPayCashOnDeliveryLearnMore = "https://woocommerce.com/document/payments/getting-started-with-in-person-payments-with-woocommerce-payments/#add-cod-payment-method" + /// URL for creating a store. + case storeCreation = "https://woocommerce.com/start" + /// Returns the URL version of the receiver /// func asURL() -> URL { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 108a059d7df..bc2206b1c23 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -242,6 +242,7 @@ 0272C00322EE9C3200D7CA2C /* AsyncDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0272C00222EE9C3200D7CA2C /* AsyncDictionary.swift */; }; 0273707E24C0047800167204 /* SequenceHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0273707D24C0047800167204 /* SequenceHelpersTests.swift */; }; 0273708024C0094500167204 /* ProductListMultiSelectorDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0273707F24C0094500167204 /* ProductListMultiSelectorDataSource.swift */; }; + 02759B9128FFA09600918176 /* StoreCreationWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02759B9028FFA09600918176 /* StoreCreationWebViewModel.swift */; }; 0277AE9B256CA8A200F45C4A /* AggregatedShippingLabelOrderItemsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277AE9A256CA8A200F45C4A /* AggregatedShippingLabelOrderItemsTests.swift */; }; 0277AEA5256CAA4200F45C4A /* MockShippingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277AEA4256CAA4200F45C4A /* MockShippingLabel.swift */; }; 0277AEAB256CAA5300F45C4A /* MockShippingLabelAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277AEAA256CAA5300F45C4A /* MockShippingLabelAddress.swift */; }; @@ -279,6 +280,7 @@ 028AFFB62484EDA000693C09 /* Dictionary+LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028AFFB52484EDA000693C09 /* Dictionary+LoggingTests.swift */; }; 028BAC3D22F2DECE008BB4AF /* StoreStatsAndTopPerformersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028BAC3C22F2DECE008BB4AF /* StoreStatsAndTopPerformersViewController.swift */; }; 028BAC4722F3B550008BB4AF /* StatsTimeRangeV4+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028BAC4622F3B550008BB4AF /* StatsTimeRangeV4+UI.swift */; }; + 028CB70F290138EF00331C09 /* Publisher+Concurrency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028CB70E290138EF00331C09 /* Publisher+Concurrency.swift */; }; 028E19BA28053443001C36E0 /* MockOrderDetailsPaymentAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028E19B928053443001C36E0 /* MockOrderDetailsPaymentAlerts.swift */; }; 028E19BC2805BD22001C36E0 /* RefundSubmissionUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028E19BB2805BD22001C36E0 /* RefundSubmissionUseCaseTests.swift */; }; 028E1F702833DD0A001F8829 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028E1F6F2833DD0A001F8829 /* DashboardViewModel.swift */; }; @@ -2159,6 +2161,7 @@ 0272C00222EE9C3200D7CA2C /* AsyncDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncDictionary.swift; sourceTree = ""; }; 0273707D24C0047800167204 /* SequenceHelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceHelpersTests.swift; sourceTree = ""; }; 0273707F24C0094500167204 /* ProductListMultiSelectorDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListMultiSelectorDataSource.swift; sourceTree = ""; }; + 02759B9028FFA09600918176 /* StoreCreationWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationWebViewModel.swift; sourceTree = ""; }; 0277AE9A256CA8A200F45C4A /* AggregatedShippingLabelOrderItemsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregatedShippingLabelOrderItemsTests.swift; sourceTree = ""; }; 0277AEA4256CAA4200F45C4A /* MockShippingLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShippingLabel.swift; sourceTree = ""; }; 0277AEAA256CAA5300F45C4A /* MockShippingLabelAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShippingLabelAddress.swift; sourceTree = ""; }; @@ -2196,6 +2199,7 @@ 028AFFB52484EDA000693C09 /* Dictionary+LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+LoggingTests.swift"; sourceTree = ""; }; 028BAC3C22F2DECE008BB4AF /* StoreStatsAndTopPerformersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsAndTopPerformersViewController.swift; sourceTree = ""; }; 028BAC4622F3B550008BB4AF /* StatsTimeRangeV4+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsTimeRangeV4+UI.swift"; sourceTree = ""; }; + 028CB70E290138EF00331C09 /* Publisher+Concurrency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Concurrency.swift"; sourceTree = ""; }; 028E19B928053443001C36E0 /* MockOrderDetailsPaymentAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOrderDetailsPaymentAlerts.swift; sourceTree = ""; }; 028E19BB2805BD22001C36E0 /* RefundSubmissionUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundSubmissionUseCaseTests.swift; sourceTree = ""; }; 028E1F6F2833DD0A001F8829 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; @@ -4416,6 +4420,14 @@ path = Variations; sourceTree = ""; }; + 02759B8F28FFA06F00918176 /* Store Creation */ = { + isa = PBXGroup; + children = ( + 02759B9028FFA09600918176 /* StoreCreationWebViewModel.swift */, + ); + path = "Store Creation"; + sourceTree = ""; + }; 0277AE99256CA86D00F45C4A /* Shipping Labels */ = { isa = PBXGroup; children = ( @@ -6639,6 +6651,7 @@ D881A318256B5C9C00FE5605 /* Navigation Exceptions */, B5A8F8AB20B88D8400D211DE /* Prologue */, B5D1AFC420BC7B3000DB0E8C /* Epilogue */, + 02759B8F28FFA06F00918176 /* Store Creation */, B55D4C0520B6027100D7A50F /* AuthenticationManager.swift */, CE16177921B7192A00B82A47 /* AuthenticationConstants.swift */, 027A2E132513124E00DA6ACB /* Keychain+Entries.swift */, @@ -7624,6 +7637,7 @@ B9B6DEEE283F8B9F00901FB7 /* Site+URL.swift */, 021DD44C286A3A8D004F0468 /* UIViewController+Navigation.swift */, DE61978A28991F0E005E4362 /* WKWebView+Authenticated.swift */, + 028CB70E290138EF00331C09 /* Publisher+Concurrency.swift */, ); path = Extensions; sourceTree = ""; @@ -9513,6 +9527,7 @@ 5739D2D426274D580020E737 /* NoSecureConnectionErrorViewModel.swift in Sources */, 7459A6C621B0680300F83A78 /* RequirementsChecker.swift in Sources */, CE1D5A55228A0AD200DF3715 /* TwoColumnTableViewCell.swift in Sources */, + 02759B9128FFA09600918176 /* StoreCreationWebViewModel.swift in Sources */, 74460D4222289C7A00D7316A /* StorePickerCoordinator.swift in Sources */, FEDD70AF26A7223500194C3A /* StorageEligibilityErrorInfo+Woo.swift in Sources */, AE9E04752776213E003FA09E /* OrderCustomerSection.swift in Sources */, @@ -10437,6 +10452,7 @@ DEF36DEA2898D3CF00178AC2 /* AuthenticatedWebViewModel.swift in Sources */, 45A0E4CB2566B56000D4E8C3 /* NumberOfLinkedProductsTableViewCell.swift in Sources */, E16715CB26663B0B00326230 /* CardPresentModalSuccessWithoutEmail.swift in Sources */, + 028CB70F290138EF00331C09 /* Publisher+Concurrency.swift in Sources */, 311F827426CD897900DF5BAD /* CardReaderSettingsAlertsProvider.swift in Sources */, CC4D1D8625E6CDDE00B6E4E7 /* RenameAttributesViewModel.swift in Sources */, DEFA3D932897D8930076FAE1 /* NoWooErrorViewModel.swift in Sources */,