diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+StoreCreation.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+StoreCreation.swift index 24d47619c0a..f80b480981b 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+StoreCreation.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+StoreCreation.swift @@ -191,6 +191,7 @@ extension WooAnalyticsEvent.StoreCreation { case profilerSellingPlatformsQuestion = "store_profiler_ecommerce_platforms" case profilerCountryQuestion = "store_profiler_country" case profilerChallengesQuestion = "store_profiler_challenges" + case profilerFeaturesQuestion = "store_profiler_features" case domainPicker = "domain_picker" case storeSummary = "store_summary" case planPurchase = "plan_purchase" diff --git a/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionOptions.swift b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionOptions.swift new file mode 100644 index 00000000000..388595f5555 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionOptions.swift @@ -0,0 +1,42 @@ +import Foundation + +extension StoreCreationFeaturesQuestionViewModel { + // TODO: 10386 Align with Android and send these via tracks + enum Feature: String, CaseIterable { + case salesAndAnalyticsReports = "sales-and-analytics-reports" + case productManagementAndInventoryTracking = "product-management-and-inventory-tracking" + case flexibleAndSecurePaymentOptions = "flexible-and-secure-payment-options" + case inPersonPayment = "in-person-payment" + case abilityToScaleAsBusinessGrows = "ability-to-scale-as-business-grows" + case customizationOptionForStoreDesign = "customization-options-for-my-store-design" + case wideRangeOfPluginsAndExtensions = "wide-range-of-plugins-and-extensions" + case others = "cthers" + } + + var features: [Feature] { + Feature.allCases + } +} + +extension StoreCreationFeaturesQuestionViewModel.Feature { + var name: String { + switch self { + case .salesAndAnalyticsReports: + return NSLocalizedString("Comprehensive sales and analytics reports", comment: "Feature option in the store creation features question.") + case .productManagementAndInventoryTracking: + return NSLocalizedString("Easy product management and inventory tracking", comment: "Feature option in the store creation features question.") + case .flexibleAndSecurePaymentOptions: + return NSLocalizedString("Flexible and secure payment options", comment: "Feature option in the store creation features question.") + case .inPersonPayment: + return NSLocalizedString("In-person payment", comment: "Feature option in the store creation features question.") + case .abilityToScaleAsBusinessGrows: + return NSLocalizedString("Ability to scale as my business grows", comment: "Feature option in the store creation features question.") + case .customizationOptionForStoreDesign: + return NSLocalizedString("Customization options for my store design", comment: "Feature option in the store creation features question.") + case .wideRangeOfPluginsAndExtensions: + return NSLocalizedString("Access to a wide range of plugins and extensions", comment: "Feature option in the store creation features question.") + case .others: + return NSLocalizedString("Others", comment: "Feature option in the store creation features question.") + } + } +} diff --git a/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionView.swift b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionView.swift new file mode 100644 index 00000000000..e8b9ff2a1fc --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Hosting controller that wraps the `StoreCreationFeaturesQuestionView`. +final class StoreCreationFeaturesQuestionHostingController: UIHostingController { + init(viewModel: StoreCreationFeaturesQuestionViewModel) { + super.init(rootView: StoreCreationFeaturesQuestionView(viewModel: viewModel)) + } + + @available(*, unavailable) + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureTransparentNavigationBar() + } +} + +/// Shows the store features question in the store creation flow. +struct StoreCreationFeaturesQuestionView: View { + @ObservedObject private var viewModel: StoreCreationFeaturesQuestionViewModel + + init(viewModel: StoreCreationFeaturesQuestionViewModel) { + self.viewModel = viewModel + } + + var body: some View { + OptionalStoreCreationProfilerQuestionView(viewModel: viewModel) { + VStack(alignment: .leading, spacing: 16) { + ForEach(viewModel.features, id: \.self) { feature in + Button(action: { + viewModel.didTapFeature(feature) + }, label: { + HStack { + Text(feature.name) + Spacer() + } + }) + .buttonStyle(SelectableSecondaryButtonStyle(isSelected: viewModel.selectedFeatures.contains(where: { $0 == feature }))) + } + } + } + } +} + +struct StoreCreationFeaturesQuestionView_Previews: PreviewProvider { + static var previews: some View { + StoreCreationFeaturesQuestionView(viewModel: .init(onContinue: { _ in }, + onSkip: {})) + } +} diff --git a/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionViewModel.swift b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionViewModel.swift new file mode 100644 index 00000000000..f027ab9e432 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Store Creation/Profiler/Features/StoreCreationFeaturesQuestionViewModel.swift @@ -0,0 +1,74 @@ +import Combine +import Foundation + +/// Necessary data from the answer of the store creation features question. +struct StoreCreationFeaturesAnswer: Equatable { + /// Display name of the selected feature. + let name: String + /// Raw value of the feature to be sent to the backend. + let value: String +} + +/// View model for `StoreCreationFeaturesQuestionView`, an optional profiler question about features in the store creation flow. +@MainActor +final class StoreCreationFeaturesQuestionViewModel: StoreCreationProfilerQuestionViewModel, ObservableObject { + typealias Answer = StoreCreationFeaturesAnswer + + let topHeader = Localization.topHeader + + let title = Localization.title + + let subtitle = Localization.subtitle + + @Published private(set) var selectedFeatures: [Feature] = [] + + private let onContinue: ([Answer]) -> Void + private let onSkip: () -> Void + + init(onContinue: @escaping ([Answer]) -> Void, + onSkip: @escaping () -> Void) { + self.onContinue = onContinue + self.onSkip = onSkip + } +} + +extension StoreCreationFeaturesQuestionViewModel: OptionalStoreCreationProfilerQuestionViewModel { + func continueButtonTapped() async { + guard selectedFeatures.isNotEmpty else { + return onSkip() + } + + onContinue(selectedFeatures.map { .init(name: $0.name, value: $0.rawValue) }) + } + + func skipButtonTapped() { + onSkip() + } +} + +extension StoreCreationFeaturesQuestionViewModel { + func didTapFeature(_ feature: Feature) { + if let alreadySelectedIndex = selectedFeatures.firstIndex(of: feature) { + selectedFeatures.remove(at: alreadySelectedIndex) + } else { + selectedFeatures.append(feature) + } + } +} + +private extension StoreCreationFeaturesQuestionViewModel { + enum Localization { + static let topHeader = NSLocalizedString( + "About you", + comment: "Top header text of the store creation profiler question about the features." + ) + static let title = NSLocalizedString( + "Which features are you most interested in?", + comment: "Title of the store creation profiler question about the features." + ) + static let subtitle = NSLocalizedString( + "Let us know what you are looking forward to using in our app.", + comment: "Subtitle of the store creation profiler question about the features." + ) + } +} diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index 79223363b7a..ad9e30e5715 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -257,6 +257,20 @@ private extension StoreCreationCoordinator { analytics.track(event: .StoreCreation.siteCreationStep(step: .profilerChallengesQuestion)) } + @MainActor + func showFeaturesQuestion(from navigationController: UINavigationController) { + let questionController = StoreCreationFeaturesQuestionHostingController(viewModel: + .init { _ in + // TODO: 10376 - Navigate to [progress view / my store tab] and pass the selected features + } onSkip: { [weak self] in + guard let self else { return } + self.analytics.track(event: .StoreCreation.siteCreationProfilerQuestionSkipped(step: .profilerFeaturesQuestion)) + // TODO: 10376 - Navigate to [progress view / my store tab] + }) + navigationController.pushViewController(questionController, animated: true) + analytics.track(event: .StoreCreation.siteCreationStep(step: .profilerFeaturesQuestion)) + } + @MainActor func showCategoryQuestion(from navigationController: UINavigationController, storeName: String) { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 568d86d2b78..bfd1db7fff9 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2280,6 +2280,10 @@ EE57C11F297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE57C11E297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift */; }; EE57C121297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE57C120297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift */; }; EE5A0A1C2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5A0A1B2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift */; }; + EE6A7BA92A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */; }; + EE6A7BAB2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */; }; + EE6A7BAD2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */; }; + EE6A7BAF2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */; }; EE6B2AD129DC522300048A8F /* StoreCreationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6B2AD029DC522300048A8F /* StoreCreationProgressView.swift */; }; EE6B2AD329DD285A00048A8F /* StoreCreationProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6B2AD229DD285900048A8F /* StoreCreationProgressViewModel.swift */; }; EE6F08662A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6F08652A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift */; }; @@ -4709,6 +4713,10 @@ EE57C11E297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ApplicationPassword.swift"; sourceTree = ""; }; EE57C120297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackEventRequestNotificationHandlerTests.swift; sourceTree = ""; }; EE5A0A1B2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+LocalNotification.swift"; sourceTree = ""; }; + EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionViewModel.swift; sourceTree = ""; }; + EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionView.swift; sourceTree = ""; }; + EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionOptions.swift; sourceTree = ""; }; + EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionViewModelTests.swift; sourceTree = ""; }; EE6B2AD029DC522300048A8F /* StoreCreationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationProgressView.swift; sourceTree = ""; }; EE6B2AD229DD285900048A8F /* StoreCreationProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationProgressViewModel.swift; sourceTree = ""; }; EE6F08652A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeTrialSurveyViewModelTests.swift; sourceTree = ""; }; @@ -4879,6 +4887,7 @@ 0201E42E2946F9F400C793C7 /* Category */, 026D4655295D7A380037F59A /* Country */, EED028622A7AB4C800C5DE03 /* Challenges */, + EE6A7BA72A7B7BC900D9A028 /* Features */, ); path = Profiler; sourceTree = ""; @@ -4901,6 +4910,7 @@ 026D464F295C08CA0037F59A /* StoreCreationSellingPlatformsQuestionViewModelTests.swift */, 022F2FA9295E8241003A0A46 /* StoreCreationCountryQuestionViewModelTests.swift */, EED028692A7B640300C5DE03 /* StoreCreationChallengesQuestionViewModelTests.swift */, + EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */, ); path = Profiler; sourceTree = ""; @@ -10647,6 +10657,16 @@ path = StoreDetails; sourceTree = ""; }; + EE6A7BA72A7B7BC900D9A028 /* Features */ = { + isa = PBXGroup; + children = ( + EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */, + EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */, + EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */, + ); + path = Features; + sourceTree = ""; + }; EE6B2ACD29DC488700048A8F /* Progress */ = { isa = PBXGroup; children = ( @@ -11990,6 +12010,7 @@ 45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */, 02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */, AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */, + EE6A7BAB2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift in Sources */, CC770C8A27B1497700CE6ABC /* SearchHeader.swift in Sources */, 02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */, 45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */, @@ -12471,6 +12492,7 @@ D81D9228222E7F0800FFA585 /* OrderStatusListViewController.swift in Sources */, CEE006082077D14C0079161F /* OrderDetailsViewController.swift in Sources */, AEB73C0C25CD734200A8454A /* AttributePickerViewModel.swift in Sources */, + EE6A7BAD2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift in Sources */, D8752EF7265E60F4008ACC80 /* PaymentCaptureCelebration.swift in Sources */, EE6B2AD129DC522300048A8F /* StoreCreationProgressView.swift in Sources */, B58B4AB62108F11C00076FDD /* Notice.swift in Sources */, @@ -12800,6 +12822,7 @@ 0396CFAD2981476900E91436 /* CardPresentModalBuiltInConnectingFailed.swift in Sources */, 02C1853B27FF0D9C00ABD764 /* RefundSubmissionUseCase.swift in Sources */, 26C98F9B29C18ACE00F96503 /* StorePlanBanner.swift in Sources */, + EE6A7BA92A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift in Sources */, E10BD16D27CF890800CE6449 /* InPersonPaymentsCountryNotSupportedStripe.swift in Sources */, 68E674AB2A4DAB8C0034BA1E /* CompletedUpgradeView.swift in Sources */, 26F94E26267A559300DB6CCF /* ProductAddOn.swift in Sources */, @@ -13417,6 +13440,7 @@ 262AF38A2713B67600E39AFF /* SimplePaymentsAmountViewModelTests.swift in Sources */, 93FA787221CD2A1A00B663E5 /* CurrencySettingsTests.swift in Sources */, 45FBDF2D238BF8BF00127F77 /* AddProductImageCollectionViewCellTests.swift in Sources */, + EE6A7BAF2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift in Sources */, 578195FC25AD1D7C004A5C12 /* OrderFulfillmentUseCaseTests.swift in Sources */, 094C161227B0604700B25F51 /* ProductVariationFormViewModelTests.swift in Sources */, EEAA45FD293073FE0047D125 /* JetpackInstallStepTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Authentication/Store Creation/Profiler/StoreCreationFeaturesQuestionViewModelTests.swift b/WooCommerce/WooCommerceTests/Authentication/Store Creation/Profiler/StoreCreationFeaturesQuestionViewModelTests.swift new file mode 100644 index 00000000000..f17d9e75896 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Authentication/Store Creation/Profiler/StoreCreationFeaturesQuestionViewModelTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import WooCommerce + +@MainActor +final class StoreCreationFeaturesQuestionViewModelTests: XCTestCase { + func test_didTapFeature_adds_feature_to_selectedFeatures() throws { + // Given + let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { _ in }, + onSkip: {}) + + // When + viewModel.didTapFeature(.productManagementAndInventoryTracking) + viewModel.didTapFeature(.abilityToScaleAsBusinessGrows) + + // Then + XCTAssertEqual(viewModel.selectedFeatures, [.productManagementAndInventoryTracking, .abilityToScaleAsBusinessGrows]) + } + + func test_didTapFeature_removes_feature_from_selectedFeatures_if_already_selected() throws { + // Given + let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { _ in }, + onSkip: {}) + + // When + viewModel.didTapFeature(.productManagementAndInventoryTracking) + viewModel.didTapFeature(.abilityToScaleAsBusinessGrows) + + // Then + XCTAssertEqual(viewModel.selectedFeatures, [.productManagementAndInventoryTracking, .abilityToScaleAsBusinessGrows]) + + // When + viewModel.didTapFeature(.productManagementAndInventoryTracking) + + // Then + XCTAssertEqual(viewModel.selectedFeatures, [.abilityToScaleAsBusinessGrows]) + } + + func test_continueButtonTapped_invokes_onContinue_after_selecting_features() throws { + let answer = waitFor { promise in + // Given + let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { answer in + promise(answer) + }, + onSkip: {}) + // When + viewModel.didTapFeature(.productManagementAndInventoryTracking) + viewModel.didTapFeature(.abilityToScaleAsBusinessGrows) + + Task { @MainActor in + await viewModel.continueButtonTapped() + } + } + + // Then + XCTAssertEqual(answer, [.init(name: StoreCreationFeaturesQuestionViewModel.Feature.productManagementAndInventoryTracking.name, + value: "product-management-and-inventory-tracking"), + .init(name: StoreCreationFeaturesQuestionViewModel.Feature.abilityToScaleAsBusinessGrows.name, + value: "ability-to-scale-as-business-grows")]) + } + + func test_continueButtonTapped_invokes_onSkip_without_selecting_a_feature() throws { + waitFor { promise in + // Given + let viewModel = StoreCreationFeaturesQuestionViewModel( onContinue: { _ in }, + onSkip: { + // Then + promise(()) + }) + // When + Task { @MainActor in + await viewModel.continueButtonTapped() + } + } + } + + func test_skipButtonTapped_invokes_onSkip() throws { + waitFor { promise in + // Given + let viewModel = StoreCreationFeaturesQuestionViewModel( onContinue: { _ in }, + onSkip: { + // Then + promise(()) + }) + // When + viewModel.skipButtonTapped() + } + } +}