diff --git a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift index 0131f547719..e91cac78c45 100644 --- a/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift +++ b/WooCommerce/Classes/POS/TabBar/POSIneligibleView.swift @@ -2,6 +2,7 @@ import SwiftUI /// A view that displays when the Point of Sale (POS) feature is not available for the current store. /// Shows the specific reason why POS is ineligible and provides a button to re-check eligibility. +@available(iOS 17.0, *) struct POSIneligibleView: View { let reason: POSIneligibleReason let onRefresh: () async throws -> Void @@ -9,47 +10,64 @@ struct POSIneligibleView: View { @State private var isLoading: Bool = false var body: some View { - VStack(spacing: POSSpacing.large) { - HStack { - Spacer() - Button { - dismiss() - } label: { - Text(Image(systemName: "xmark")) - .font(POSFontStyle.posButtonSymbolLarge.font()) - } - .foregroundColor(Color.posOnSurfaceVariantLowest) - } - + VStack(spacing: POSSpacing.none) { Spacer() - VStack(spacing: POSSpacing.medium) { + VStack(alignment: .center, spacing: POSSpacing.none) { Image(PointOfSaleAssets.exclamationMark.imageName) .resizable() .frame(width: POSErrorAndAlertIconSize.large.dimension, height: POSErrorAndAlertIconSize.large.dimension) - Text(reasonText) - .font(POSFontStyle.posHeadingBold.font()) - .multilineTextAlignment(.center) - .foregroundColor(Color.posOnSurface) - - Button { - Task { @MainActor in - do { - isLoading = true - try await onRefresh() - isLoading = false - } catch { - // TODO-jc: handle error if needed, e.g., show an error message - print("Error refreshing eligibility: \(error)") - isLoading = false + Spacer() + .frame(height: POSSpacing.medium) + + VStack(spacing: POSSpacing.small) { + Text(Localization.title) + .font(POSFontStyle.posHeadingBold.font()) + .multilineTextAlignment(.center) + .foregroundColor(Color.posOnSurface) + + Text(suggestionText) + .font(POSFontStyle.posBodyLargeRegular().font()) + .multilineTextAlignment(.center) + .foregroundColor(Color.posOnSurface) + } + .containerRelativeFrame(.horizontal) { length, _ in + max(length * 0.5, 300) + } + + Spacer() + .frame(height: POSSpacing.large) + + VStack(spacing: POSSpacing.medium) { + Button { + Task { @MainActor in + do { + isLoading = true + try await onRefresh() + isLoading = false + } catch { + // TODO: WOOMOB-720 - handle error if needed, e.g., show an error message + DDLogError("Error refreshing eligibility: \(error)") + isLoading = false + } } + } label: { + Text(Localization.refreshEligibility) } - } label: { - Text(Localization.refreshEligibility) + .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading)) + + Button { + dismiss() + } label: { + Text(Localization.dismiss) + } + .buttonStyle(POSOutlinedButtonStyle(size: .normal)) + } + .containerRelativeFrame(.horizontal) { length, _ in + max(length * 0.5 - 132, 300) } - .buttonStyle(POSFilledButtonStyle(size: .normal, isLoading: isLoading)) } Spacer() @@ -57,74 +75,186 @@ struct POSIneligibleView: View { .padding(POSPadding.large) } - private var reasonText: String { + private var suggestionText: String { switch reason { case .notTablet: - return NSLocalizedString("pos.ineligible.reason.notTablet", - value: "POS is only available on iPad.", - comment: "Ineligible reason: not a tablet") + return NSLocalizedString("pos.ineligible.suggestion.notTablet", + value: "Please use a tablet to access POS features.", + comment: "Suggestion for not tablet: use iPad") case .unsupportedIOSVersion: - return NSLocalizedString("pos.ineligible.reason.unsupportedIOSVersion", - value: "POS requires a newer version of iOS 17 and above.", - comment: "Ineligible reason: iOS version too low") - case .unsupportedWooCommerceVersion: - return NSLocalizedString("pos.ineligible.reason.unsupportedWooCommerceVersion", - value: "Please update WooCommerce plugin to use POS.", - comment: "Ineligible reason: WooCommerce version too low") + return NSLocalizedString("pos.ineligible.suggestion.unsupportedIOSVersion", + value: "Point of Sale requires iOS 17 or later. Please update your device to iOS 17+ to use this feature.", + comment: "Suggestion for unsupported iOS version: update iOS") + case let .unsupportedWooCommerceVersion(minimumVersion): + let format = NSLocalizedString("pos.ineligible.suggestion.unsupportedWooCommerceVersion", + value: "Your WooCommerce version is not supported. " + + "The POS system requires WooCommerce version %1$@ or above. Please update WooCommerce to the latest version.", + comment: "Suggestion for unsupported WooCommerce version: update plugin. " + + "%1$@ is a placeholder for the minimum required version.") + return String.localizedStringWithFormat(format, minimumVersion) case .wooCommercePluginNotFound: - return NSLocalizedString("pos.ineligible.reason.wooCommercePluginNotFound", - value: "WooCommerce plugin not found.", - comment: "Ineligible reason: plugin missing") + return NSLocalizedString("pos.ineligible.suggestion.wooCommercePluginNotFound", + value: "Install and activate the WooCommerce plugin from your WordPress admin.", + comment: "Suggestion for missing WooCommerce plugin: install plugin") case .featureSwitchDisabled: - return NSLocalizedString("pos.ineligible.reason.featureSwitchDisabled", - value: "POS feature is not enabled for your store.", - comment: "Ineligible reason: feature switch off") + return NSLocalizedString("pos.ineligible.suggestion.featureSwitchDisabled", + value: "Point of Sale must be enabled to proceed. " + + "Please enable the POS feature from your WordPress admin under WooCommerce settings > Advanced > Features.", + comment: "Suggestion for disabled feature switch: enable feature in WooCommerce settings") case .featureSwitchSyncFailure: - return NSLocalizedString("pos.ineligible.reason.featureSwitchSyncFailure", - value: "Could not verify POS feature status.", - comment: "Ineligible reason: feature switch sync failed") - case .unsupportedCountry: - return NSLocalizedString("pos.ineligible.reason.unsupportedCountry", - value: "POS is not available in your country.", - comment: "Ineligible reason: country not supported") - case .unsupportedCurrency: - return NSLocalizedString("pos.ineligible.reason.unsupportedCurrency", - value: "POS is not available for your store's currency.", - comment: "Ineligible reason: currency not supported") + return NSLocalizedString("pos.ineligible.suggestion.featureSwitchSyncFailure", + value: "Try relaunching the app or check your internet connection and try again.", + comment: "Suggestion for feature switch sync failure: relaunch or check connection") + case let .unsupportedCountry(supportedCountries): + let countryNames = supportedCountries.map { $0.readableCountry } + let formattedCountryList = ListFormatter.localizedString(byJoining: countryNames) + let format = NSLocalizedString( + "pos.ineligible.suggestion.unsupportedCountry", + value: "POS is currently only available in %1$@. Check back later for availability in your region.", + comment: "Suggestion for unsupported country with list of supported countries. " + + "%1$@ is a placeholder for the localized list of supported country names." + ) + return String.localizedStringWithFormat(format, formattedCountryList) + case let .unsupportedCurrency(supportedCurrencies): + let currencyList = supportedCurrencies.map { $0.rawValue } + let formattedCurrencyList = ListFormatter.localizedString(byJoining: currencyList) + let format = NSLocalizedString( + "pos.ineligible.suggestion.unsupportedCurrency", + value: "The POS system is not available for your store’s currency. It currently supports only %1$@. " + + "Please check your store currency settings or contact support for assistance.", + comment: "Suggestion for unsupported currency with list of supported currencies. " + + "%1$@ is a placeholder for the localized list of supported currency codes." + ) + return String.localizedStringWithFormat(format, formattedCurrencyList) case .siteSettingsNotAvailable: - return NSLocalizedString("pos.ineligible.reason.siteSettingsNotAvailable", - value: "Unable to load store settings for POS.", - comment: "Ineligible reason: site settings unavailable") + return NSLocalizedString("pos.ineligible.suggestion.siteSettingsNotAvailable", + value: "Check your internet connection and try relaunching the app. If the issue persists, please contact support.", + comment: "Suggestion for site settings unavailable: check connection or contact support") case .featureFlagDisabled: - return NSLocalizedString("pos.ineligible.reason.featureFlagDisabled", - value: "POS feature is currently disabled.", - comment: "Ineligible reason: feature flag disabled") + return NSLocalizedString("pos.ineligible.suggestion.featureFlagDisabled", + value: "POS is currently disabled.", + comment: "Suggestion for disabled feature flag: notify that POS is disabled remotely") case .selfDeallocated: - return Localization.defaultReason + return NSLocalizedString("pos.ineligible.suggestion.selfDeallocated", + value: "Try relaunching the app to resolve this issue.", + comment: "Suggestion for self deallocated: relaunch") } } } +@available(iOS 17.0, *) private extension POSIneligibleView { enum Localization { + static let title = NSLocalizedString( + "pos.ineligible.title", + value: "Unable to load", + comment: "Title shown in POS ineligible view" + ) + static let refreshEligibility = NSLocalizedString( "pos.ineligible.refresh.button.title", - value: "Check Eligibility Again", + value: "Retry", comment: "Button title to refresh POS eligibility check" ) - /// Default message shown when POS eligibility reason is not available. - static let defaultReason = NSLocalizedString( - "pos.ineligible.default.reason", - value: "Your store is not eligible for POS at this time.", - comment: "Default message shown when POS eligibility reason is not available" + static let dismiss = NSLocalizedString( + "pos.ineligible.dismiss.button.title", + value: "Exit POS", + comment: "Button title to dismiss POS ineligible view" + ) + } +} + +#if DEBUG + +#Preview("Unsupported currency") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .unsupportedCurrency(supportedCurrencies: [.USD]), + onRefresh: {} + ) + } +} + +#Preview("Unsupported country") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .unsupportedCountry(supportedCountries: [.US, .GB]), + onRefresh: {} ) } } -#Preview { - POSIneligibleView( - reason: .unsupportedCurrency, - onRefresh: {} - ) +#Preview("Not a tablet") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .notTablet, + onRefresh: {} + ) + } } + +#Preview("Unsupported iOS version") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .unsupportedIOSVersion, + onRefresh: {} + ) + } +} + +#Preview("WooCommerce plugin not found") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .wooCommercePluginNotFound, + onRefresh: {} + ) + } +} + +#Preview("Feature flag disabled") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .featureFlagDisabled, + onRefresh: {} + ) + } +} + +#Preview("Feature switch disabled") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .featureSwitchDisabled, + onRefresh: {} + ) + } +} + +#Preview("Site settings unavailable") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .siteSettingsNotAvailable, + onRefresh: {} + ) + } +} + +#Preview("Feature switch sync failure") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .featureSwitchSyncFailure, + onRefresh: {} + ) + } +} + +#Preview("Unsupported WooCommerce version") { + if #available(iOS 17.0, *) { + POSIneligibleView( + reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0"), + onRefresh: {} + ) + } +} + +#endif diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 8278571aeaa..653d977aa01 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -19,14 +19,14 @@ import class Yosemite.PluginsService enum POSIneligibleReason: Equatable { case notTablet case unsupportedIOSVersion - case unsupportedWooCommerceVersion + case unsupportedWooCommerceVersion(minimumVersion: String) case siteSettingsNotAvailable case wooCommercePluginNotFound case featureFlagDisabled case featureSwitchDisabled case featureSwitchSyncFailure - case unsupportedCountry - case unsupportedCurrency + case unsupportedCountry(supportedCountries: [CountryCode]) + case unsupportedCurrency(supportedCurrencies: [CurrencyCode]) case selfDeallocated } @@ -153,7 +153,7 @@ private extension POSTabEligibilityChecker { case .eligible: break case let .ineligible(reason): - if reason == .unsupportedCurrency { + if case .unsupportedCurrency = reason { break } else { return false @@ -178,7 +178,7 @@ private extension POSTabEligibilityChecker { guard VersionHelpers.isVersionSupported(version: wcPlugin.version, minimumRequired: Constants.wcPluginMinimumVersion) else { - return .ineligible(reason: .unsupportedWooCommerceVersion) + return .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: Constants.wcPluginMinimumVersion)) } // For versions below 10.0.0, the feature is enabled by default. @@ -250,21 +250,20 @@ private extension POSTabEligibilityChecker { } func isEligibleFromCountryAndCurrencyCode(countryCode: CountryCode, currencyCode: CurrencyCode) -> POSEligibilityState { + let supportedCountries: [CountryCode] = [.US, .GB] + let supportedCurrencies: [CountryCode: [CurrencyCode]] = [.US: [.USD], + .GB: [.GBP]] + // Checks country first. - switch countryCode { - case .US, .GB: - break - default: - return .ineligible(reason: .unsupportedCountry) + guard supportedCountries.contains(countryCode) else { + return .ineligible(reason: .unsupportedCountry(supportedCountries: supportedCountries)) } - // Then checks currency based on the country. - switch (countryCode, currencyCode) { - case (.US, .USD), (.GB, .GBP): - return .eligible - default: - return .ineligible(reason: .unsupportedCurrency) + let supportedCurrenciesForCountry = supportedCurrencies[countryCode] ?? [] + guard supportedCurrenciesForCountry.contains(currencyCode) else { + return .ineligible(reason: .unsupportedCurrency(supportedCurrencies: supportedCurrenciesForCountry)) } + return .eligible } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift index e0318b4e824..9706b1319b2 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSTabEligibilityCheckerTests.swift @@ -131,20 +131,23 @@ struct POSTabEligibilityCheckerTests { let result = await checker.checkEligibility() // Then - #expect(result == .ineligible(reason: .unsupportedCountry)) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } @Test(arguments: [ - (country: Country.us, currency: CurrencyCode.GBP, isPointOfSaleAsATabi2Enabled: true), - (country: Country.us, currency: CurrencyCode.GBP, isPointOfSaleAsATabi2Enabled: false), - (country: Country.us, currency: CurrencyCode.CAD, isPointOfSaleAsATabi2Enabled: true), - (country: Country.us, currency: CurrencyCode.CAD, isPointOfSaleAsATabi2Enabled: false), - (country: Country.gb, currency: CurrencyCode.EUR, isPointOfSaleAsATabi2Enabled: true), - (country: Country.gb, currency: CurrencyCode.EUR, isPointOfSaleAsATabi2Enabled: false), - (country: Country.gb, currency: CurrencyCode.USD, isPointOfSaleAsATabi2Enabled: true), - (country: Country.gb, currency: CurrencyCode.USD, isPointOfSaleAsATabi2Enabled: false) + (country: Country.us, currency: CurrencyCode.GBP, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: true), + (country: Country.us, currency: CurrencyCode.GBP, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: false), + (country: Country.us, currency: CurrencyCode.CAD, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: true), + (country: Country.us, currency: CurrencyCode.CAD, expectedSupportedCurrencies: [CurrencyCode.USD], isPointOfSaleAsATabi2Enabled: false), + (country: Country.gb, currency: CurrencyCode.EUR, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: true), + (country: Country.gb, currency: CurrencyCode.EUR, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: false), + (country: Country.gb, currency: CurrencyCode.USD, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: true), + (country: Country.gb, currency: CurrencyCode.USD, expectedSupportedCurrencies: [CurrencyCode.GBP], isPointOfSaleAsATabi2Enabled: false) ]) - fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, currency: CurrencyCode, isPointOfSaleAsATabi2Enabled: Bool) async throws { + fileprivate func is_ineligible_when_currency_is_not_supported(country: Country, + currency: CurrencyCode, + expectedSupportedCurrencies: [CurrencyCode], + isPointOfSaleAsATabi2Enabled: Bool) async throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleAsATabi2Enabled: isPointOfSaleAsATabi2Enabled) setupCountry(country: country, currency: currency) @@ -160,7 +163,7 @@ struct POSTabEligibilityCheckerTests { let result = await checker.checkEligibility() // Then - #expect(result == .ineligible(reason: .unsupportedCurrency)) + #expect(result == .ineligible(reason: .unsupportedCurrency(supportedCurrencies: expectedSupportedCurrencies))) } @Test(arguments: [true, false]) @@ -181,7 +184,7 @@ struct POSTabEligibilityCheckerTests { let result = await checker.checkEligibility() // Then - #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion)) + #expect(result == .ineligible(reason: .unsupportedWooCommerceVersion(minimumVersion: "9.6.0-beta"))) } @Test(arguments: [true, false]) @@ -342,7 +345,7 @@ struct POSTabEligibilityCheckerTests { let result = await checker.checkEligibility() // Then - Should be ineligible because fresh settings show CA (not cached US) - #expect(result == .ineligible(reason: .unsupportedCountry)) + #expect(result == .ineligible(reason: .unsupportedCountry(supportedCountries: [.US, .GB]))) } @Test(arguments: [true, false])