From b17dc9014c1c56c7feffaf458fb0d4155cfe1337 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 14:35:06 +0200 Subject: [PATCH 1/6] Make OrderState support different types of errors With this commit the functionality doesn't change - PointOfSaleOrderSyncErrorMessageView is presented with an error that is returned by the API. However, now we can distinguish between coupon and other errors. --- .../PointOfSaleOrderController.swift | 16 ++++---- .../POS/Models/PointOfSaleOrderState.swift | 20 +++++++++- ...PointOfSaleOrderSyncErrorMessageView.swift | 29 ++++++++++++--- ...OfSaleOrderSyncErrorMessageViewModel.swift | 37 ------------------- .../Classes/POS/Presentation/TotalsView.swift | 7 +++- .../WooCommerce.xcodeproj/project.pbxproj | 4 -- 6 files changed, 54 insertions(+), 59 deletions(-) delete mode 100644 WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageViewModel.swift diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 7be66441d1a..44961159a66 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -103,12 +103,11 @@ protocol PointOfSaleOrderControllerProtocol { private func setOrderStateToError(_ error: Error, retryHandler: @escaping () async -> Void) { // Consider removing error or handle specific errors with our own formatting and localization - orderState = .error(.init(message: error.localizedDescription, - handler: { + orderState = .error(.other(error.localizedDescription), { Task { await retryHandler() } - })) + }) } func sendReceipt(recipientEmail: String) async throws { @@ -192,7 +191,7 @@ enum PointOfSaleInternalOrderState { case idle case syncing case loaded(PointOfSaleOrderTotals, Order) - case error(PointOfSaleOrderSyncErrorMessageViewModel) + case error(PointOfSaleOrderState.OrderStateError, PointOfSaleOrderState.OrderStateRetryHandler) var isSyncing: Bool { switch self { @@ -207,8 +206,8 @@ enum PointOfSaleInternalOrderState { switch self { case .idle: return .idle - case .error(let error): - return .error(error) + case .error(let error, let handler): + return .error(error, handler) case .loaded(let totals, _): return .loaded(totals) case .syncing: @@ -222,9 +221,8 @@ extension PointOfSaleInternalOrderState: Equatable { switch (lhs, rhs) { case (.idle, .idle): return true - case (.error(let lhsError), .error(let rhsError)): - return lhsError.title == rhsError.title && - lhsError.message == rhsError.message + case (.error(let lhsError, _), .error(let rhsError, _)): + return lhsError == rhsError case (.syncing, .syncing): return true case (.loaded(let lhsTotals, let lhsOrder), .loaded(let rhsTotals, let rhsOrder)): diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderState.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderState.swift index 412fb4b0cd6..8813f9077d2 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderState.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderState.swift @@ -4,7 +4,25 @@ enum PointOfSaleOrderState: Equatable { case idle case syncing case loaded(PointOfSaleOrderTotals) - case error(PointOfSaleOrderSyncErrorMessageViewModel) + case error(OrderStateError, OrderStateRetryHandler) + + typealias OrderStateRetryHandler = () -> Void + + enum OrderStateError: Equatable { + case other(String) + case invalidCoupon(String) + + static func == (lhs: OrderStateError, rhs: OrderStateError) -> Bool { + switch (lhs, rhs) { + case (.other(let lhsError), .other(let rhsError)): + return lhsError == rhsError + case (.invalidCoupon(let lhsCoupon), .invalidCoupon(let rhsCoupon)): + return lhsCoupon == rhsCoupon + default: + return false + } + } + } static func == (lhs: PointOfSaleOrderState, rhs: PointOfSaleOrderState) -> Bool { switch (lhs, rhs) { diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift index eadc2a91bf3..3e7fa741b18 100644 --- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift +++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift @@ -1,7 +1,8 @@ import SwiftUI struct PointOfSaleOrderSyncErrorMessageView: View { - let viewModel: PointOfSaleOrderSyncErrorMessageViewModel + let message: String + let handler: () -> Void var body: some View { HStack(alignment: .center) { @@ -11,17 +12,17 @@ struct PointOfSaleOrderSyncErrorMessageView: View { POSErrorExclamationMark(size: .large) Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.imageAndTextSpacing) VStack(alignment: .center, spacing: PointOfSaleCardPresentPaymentLayout.textSpacing) { - Text(viewModel.title) + Text(Localization.title) .foregroundStyle(Color.posOnSurface) .font(.posHeadingBold) - Text(viewModel.message) + Text(message) .foregroundStyle(Color.posOnSurface) .font(.posBodyLargeRegular()) .padding([.leading, .trailing]) } Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing) - Button(viewModel.actionModel.title, action: viewModel.actionModel.handler) + Button(Localization.actionTitle, action: handler) .buttonStyle(POSFilledButtonStyle(size: .normal)) .padding([.leading, .trailing], Constants.buttonSidePadding) .padding([.bottom], Constants.buttonBottomPadding) @@ -42,7 +43,23 @@ private extension PointOfSaleOrderSyncErrorMessageView { } } +private extension PointOfSaleOrderSyncErrorMessageView { + enum Localization { + static let title = NSLocalizedString( + "pointOfSale.orderSync.error.title", + value: "Couldn't load totals", + comment: "Title of the error when failing to synchronize order and calculate order totals" + ) + + static let actionTitle = NSLocalizedString( + "pointOfSale.orderSync.error.tryAgain", + value: "Try again", + comment: "Button title to retry synchronizing order and calculating order totals" + ) + } +} + +private struct TestError: Error {} #Preview { - PointOfSaleOrderSyncErrorMessageView(viewModel: PointOfSaleOrderSyncErrorMessageViewModel(message: "An error happened!", - handler: {})) + PointOfSaleOrderSyncErrorMessageView(message: "An error happened!") {} } diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageViewModel.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageViewModel.swift deleted file mode 100644 index 010da6ff29a..00000000000 --- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageViewModel.swift +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -final class PointOfSaleOrderSyncErrorMessageViewModel: ObservableObject { - struct ActionModel { - let title: String = Localization.actionTitle - let handler: () -> Void - - init(handler: @escaping () -> Void) { - self.handler = handler - } - } - - let title: String = Localization.title - let message: String - let actionModel: ActionModel - - init(message: String, handler: @escaping () -> Void) { - self.message = message - self.actionModel = .init(handler: handler) - } -} - -private extension PointOfSaleOrderSyncErrorMessageViewModel { - enum Localization { - static let title = NSLocalizedString( - "pointOfSale.orderSync.error.title", - value: "Couldn't load totals", - comment: "Title of the error when failing to synchronize order and calculate order totals" - ) - - static let actionTitle = NSLocalizedString( - "pointOfSale.orderSync.error.tryAgain", - value: "Try again", - comment: "Button title to retry synchronizing order and calculating order totals" - ) - } -} diff --git a/WooCommerce/Classes/POS/Presentation/TotalsView.swift b/WooCommerce/Classes/POS/Presentation/TotalsView.swift index ae434d7e935..1ea379f9aae 100644 --- a/WooCommerce/Classes/POS/Presentation/TotalsView.swift +++ b/WooCommerce/Classes/POS/Presentation/TotalsView.swift @@ -75,8 +75,11 @@ struct TotalsView: View { cardReaderConnectionStatus: posModel.cardReaderConnectionStatus)) } .animation(.default, value: isShowingPaymentView) - case .error(let viewModel): - PointOfSaleOrderSyncErrorMessageView(viewModel: viewModel) + case .error(.other(let message), let handler): + PointOfSaleOrderSyncErrorMessageView(message: message, handler: handler) + .transition(.opacity) + case .error(.invalidCoupon(let message), let handler): + PointOfSaleOrderSyncErrorMessageView(message: message, handler: handler) .transition(.opacity) } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 8d2f3d20d50..c5b9b400531 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -31,7 +31,6 @@ 013D2FB42CFEFEC600845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB32CFEFEA800845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift */; }; 013D2FB62CFF54BB00845D75 /* TapToPayEducationStepsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */; }; 014BD4B82C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */; }; - 014BD4BA2C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014BD4B92C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift */; }; 0157A9962C4FEA7200866FFD /* PointOfSaleLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */; }; 015D99AA2C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */; }; 01620C4E2C5394B200D3EA2F /* POSProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */; }; @@ -3264,7 +3263,6 @@ 013D2FB32CFEFEA800845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuiltInCardReaderMerchantEducationPresenter.swift; sourceTree = ""; }; 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationStepsFactory.swift; sourceTree = ""; }; 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderSyncErrorMessageView.swift; sourceTree = ""; }; - 014BD4B92C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderSyncErrorMessageViewModel.swift; sourceTree = ""; }; 0157A9952C4FEA7200866FFD /* PointOfSaleLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleLoadingView.swift; sourceTree = ""; }; 015D99A92C58C780001D7186 /* PointOfSaleCardPresentPaymentLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentLayout.swift; sourceTree = ""; }; 01620C4D2C5394B200D3EA2F /* POSProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProgressViewStyle.swift; sourceTree = ""; }; @@ -6519,7 +6517,6 @@ isa = PBXGroup; children = ( 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */, - 014BD4B92C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift */, ); path = "Order Messages"; sourceTree = ""; @@ -17231,7 +17228,6 @@ CE8CCD43239AC06E009DBD22 /* RefundDetailsViewController.swift in Sources */, 869C2AA42C91791B00DDEE13 /* AztecEditorView.swift in Sources */, B560D68C2195BD1E0027BB7E /* NoteDetailsCommentTableViewCell.swift in Sources */, - 014BD4BA2C64FC0E0011A66E /* PointOfSaleOrderSyncErrorMessageViewModel.swift in Sources */, DEA88F502AA9D0100037273B /* AddEditProductCategoryViewModel.swift in Sources */, 451C77712404518600413F73 /* ProductSettingsRows.swift in Sources */, 02B191522CCF28E600CF38C9 /* PointOfSaleCardPresentPaymentOnboardingViewModel.swift in Sources */, From 732446689ec6f143c7b47f69ebde286117b474b2 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:12:55 +0200 Subject: [PATCH 2/6] Create CouponsError for parsing invalid coupon with self-hosted and Jetpack-connected sites --- .../PointOfSaleOrderController.swift | 20 ++++--- .../PointOfSaleOrderControllerTests.swift | 4 +- Yosemite/Yosemite.xcodeproj/project.pbxproj | 4 ++ Yosemite/Yosemite/Stores/CouponsError.swift | 54 +++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 Yosemite/Yosemite/Stores/CouponsError.swift diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 44961159a66..f56c127f27e 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -8,6 +8,7 @@ import struct Yosemite.PaymentGateway import struct Yosemite.POSCart import struct Yosemite.POSCartItem import struct Yosemite.POSCoupon +import struct Yosemite.CouponsError import enum Yosemite.OrderAction import enum Yosemite.OrderUpdateField import class WooFoundation.CurrencyFormatter @@ -102,12 +103,19 @@ protocol PointOfSaleOrderControllerProtocol { private func setOrderStateToError(_ error: Error, retryHandler: @escaping () async -> Void) { - // Consider removing error or handle specific errors with our own formatting and localization - orderState = .error(.other(error.localizedDescription), { - Task { - await retryHandler() - } - }) + if let couponsError = CouponsError(underlyingError: error) { + orderState = .error(.invalidCoupon(couponsError.message), { + Task { + await retryHandler() + } + }) + } else { + orderState = .error(.other(error.localizedDescription), { + Task { + await retryHandler() + } + }) + } } func sendReceipt(recipientEmail: String) async throws { diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index 4dba3fd8754..1c43ee8b6d3 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -179,9 +179,7 @@ struct PointOfSaleOrderControllerTests { #expect(orderStates == [ .idle, .syncing, - .error(.init( - message: MockPOSOrderServiceError.noOrderToReturn.localizedDescription, - handler: {})) + .error(.other(MockPOSOrderServiceError.noOrderToReturn.localizedDescription), {}) ]) } diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index 6372f52d376..863992a1f30 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0139C2B02D91D1C600C78FDE /* POSCart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0139C2AF2D91D1C400C78FDE /* POSCart.swift */; }; 016EFCAE2C4155650016BDAA /* OrderItem+BasePrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016EFCAD2C4155650016BDAA /* OrderItem+BasePrice.swift */; }; 016EFCB02C41559D0016BDAA /* OrderItem+BasePriceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 016EFCAF2C41559D0016BDAA /* OrderItem+BasePriceTests.swift */; }; + 01AAD8122D92DE110081D60B /* CouponsError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AAD8112D92DE0F0081D60B /* CouponsError.swift */; }; 0202B690238790E200F3EBE0 /* ProductsFeatureSwitchPListWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B68F238790E200F3EBE0 /* ProductsFeatureSwitchPListWrapper.swift */; }; 0202B6972387AFBF00F3EBE0 /* MockInMemoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B6962387AFBF00F3EBE0 /* MockInMemoryStorage.swift */; }; 0202B6992387B01500F3EBE0 /* AppSettingsStoreTests+ProductsFeatureSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0202B6982387B01500F3EBE0 /* AppSettingsStoreTests+ProductsFeatureSwitch.swift */; }; @@ -555,6 +556,7 @@ 0139C2AF2D91D1C400C78FDE /* POSCart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCart.swift; sourceTree = ""; }; 016EFCAD2C4155650016BDAA /* OrderItem+BasePrice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderItem+BasePrice.swift"; sourceTree = ""; }; 016EFCAF2C41559D0016BDAA /* OrderItem+BasePriceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderItem+BasePriceTests.swift"; sourceTree = ""; }; + 01AAD8112D92DE0F0081D60B /* CouponsError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponsError.swift; sourceTree = ""; }; 0202B68F238790E200F3EBE0 /* ProductsFeatureSwitchPListWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFeatureSwitchPListWrapper.swift; sourceTree = ""; }; 0202B6962387AFBF00F3EBE0 /* MockInMemoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockInMemoryStorage.swift; sourceTree = ""; }; 0202B6982387B01500F3EBE0 /* AppSettingsStoreTests+ProductsFeatureSwitch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppSettingsStoreTests+ProductsFeatureSwitch.swift"; sourceTree = ""; }; @@ -1730,6 +1732,7 @@ DED91DEF2AD6756600CDCC53 /* BlazeStore.swift */, D83B093825DECFD800B21F45 /* CardPresentPaymentStore.swift */, 204CBD2E2D43C516006FF89A /* PaymentsError.swift */, + 01AAD8112D92DE0F0081D60B /* CouponsError.swift */, 741F34812195EA71005F5BD9 /* CommentStore.swift */, 03FBDA25263296A100ACE257 /* CouponStore.swift */, 45E462132684C92D00011BF2 /* DataStore.swift */, @@ -2672,6 +2675,7 @@ 247CE84A2583246800F9D9D1 /* MockOrderStatusActionHandler.swift in Sources */, B9AECD402850FE4600E78584 /* Order+CardPresentPayment.swift in Sources */, 6823533F2D82A90C00F24470 /* POSCoupon.swift in Sources */, + 01AAD8122D92DE110081D60B /* CouponsError.swift in Sources */, 7492FADB217FAE4D00ED2C69 /* SiteSetting+ReadOnlyType.swift in Sources */, 209AD3CE2AC1A9C200825D76 /* WooPaymentsPayoutsOverview.swift in Sources */, DEDA8DAF2B1847C80076BF0F /* WordPressThemeStore.swift in Sources */, diff --git a/Yosemite/Yosemite/Stores/CouponsError.swift b/Yosemite/Yosemite/Stores/CouponsError.swift new file mode 100644 index 00000000000..43da1c1d026 --- /dev/null +++ b/Yosemite/Yosemite/Stores/CouponsError.swift @@ -0,0 +1,54 @@ +import Foundation +import Networking + +public struct CouponsError: Error, LocalizedError { + public let message: String + public let underlyingError: Error + + public init?(underlyingError error: Error) { + switch error { + case DotcomError.unknown(Constants.invalidCouponCode, let message): + self.message = message ?? Localizations.defaultCouponsError + self.underlyingError = error + case let NetworkError.unacceptableStatusCode(_, response): + guard let response, + let errorDetails = try? JSONDecoder().decode(ErrorDetails.self, from: response), + errorDetails.code == Constants.invalidCouponCode + else { + return nil + } + self.message = errorDetails.message ?? Localizations.defaultCouponsError + self.underlyingError = error + default: + return nil + } + } + + public init(message: String, underlyingError: Error) { + self.message = message + self.underlyingError = underlyingError + } + + + private struct ErrorDetails: Decodable { + let code: String + let message: String? + + enum CodingKeys: CodingKey { + case code + case message + } + } + + enum Localizations { + static let defaultCouponsError = NSLocalizedString( + "couponsError.default.title", + value: "The coupon could not be applied due to an unexpected error.", + comment: "A default error when coupon application failed." + ) + } + + struct Constants { + static let invalidCouponCode = "woocommerce_rest_invalid_coupon" + } +} From 2cf3148263fef275fa9c2a1422120ecff553f378 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:30:02 +0200 Subject: [PATCH 3/6] Create PointOfSaleOrderSyncCouponsErrorMessageView Show PointOfSaleOrderSyncCouponsErrorMessageView when invalidCoupons error happens during Order creation. Remove coupons from cart before retrying. --- .../Models/PointOfSaleAggregateModel.swift | 5 ++ ...SaleOrderSyncCouponsErrorMessageView.swift | 76 +++++++++++++++++++ ...PointOfSaleOrderSyncErrorMessageView.swift | 5 +- .../Classes/POS/Presentation/TotalsView.swift | 4 +- .../WooCommerce.xcodeproj/project.pbxproj | 4 + .../Mocks/MockPointOfSaleAggregateModel.swift | 2 + 6 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index 6f65bedc293..f2588cf546f 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -33,6 +33,7 @@ protocol PointOfSaleAggregateModelProtocol { func remove(cartItem: CartItem) func remove(cartCouponItem: CartCouponItem) func removeAllItemsFromCart() + func removeAllCouponsFromCart() func addMoreToCart() func startNewCart() @@ -128,6 +129,10 @@ extension PointOfSaleAggregateModel { cart.removeAll() } + func removeAllCouponsFromCart() { + cart.coupons.removeAll() + } + func addMoreToCart() { setStateForEditing() } diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift new file mode 100644 index 00000000000..ed8e1731c8f --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +@available(iOS 17.0, *) +struct PointOfSaleOrderSyncCouponsErrorMessageView: View { + let message: String + let retryHandler: () -> Void + + @Environment(PointOfSaleAggregateModel.self) private var posModel + + var body: some View { + HStack(alignment: .center) { + Spacer() + VStack(alignment: .center, spacing: POSSpacing.none) { + Spacer() + POSErrorExclamationMark(size: .large) + Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.imageAndTextSpacing) + VStack(alignment: .center, spacing: PointOfSaleCardPresentPaymentLayout.textSpacing) { + Text("Invalid coupons") + .foregroundStyle(Color.posOnSurface) + .font(.posHeadingBold) + + Text(message) + .foregroundStyle(Color.posOnSurface) + .font(.posBodyLargeRegular()) + .padding([.leading, .trailing]) + } + Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing) + Button("Continue without coupons", action: { + posModel.removeAllCouponsFromCart() + retryHandler() + }) + .buttonStyle(POSFilledButtonStyle(size: .normal)) + .padding([.leading, .trailing], Constants.buttonSidePadding) + .padding([.bottom], Constants.buttonBottomPadding) + + Spacer() + } + .multilineTextAlignment(.center) + Spacer() + } + } +} + +@available(iOS 17.0, *) +private extension PointOfSaleOrderSyncCouponsErrorMessageView { + enum Constants { + static let headerSpacing: CGFloat = POSSpacing.large + static let textSpacing: CGFloat = POSSpacing.medium + static let buttonSidePadding: CGFloat = POSPadding.xxLarge + static let buttonBottomPadding: CGFloat = POSPadding.medium + } +} + +// MARK: - TODO when copy is finalized +// +//private extension PointOfSaleOrderSyncErrorMessageView { +// enum Localization { +// static let title = NSLocalizedString( +// "pointOfSale.orderSync.couponsError.title", +// value: "Invalid coupons", +// comment: "Title of the error when failing to validate coupons and calculate order totals" +// ) +// +// static let actionTitle = NSLocalizedString( +// "pointOfSale.orderSync.couponsError.proceed", +// value: "Continue without coupons", +// comment: "Button title to remove coupons and retry synchronizing order and calculating order totals" +// ) +// } +//} + +#Preview { + if #available(iOS 17.0, *) { + PointOfSaleOrderSyncCouponsErrorMessageView(message: "An error happened!") {} + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift index 3e7fa741b18..4f4ee0a9fb3 100644 --- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift +++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncErrorMessageView.swift @@ -2,7 +2,7 @@ import SwiftUI struct PointOfSaleOrderSyncErrorMessageView: View { let message: String - let handler: () -> Void + let retryHandler: () -> Void var body: some View { HStack(alignment: .center) { @@ -22,7 +22,7 @@ struct PointOfSaleOrderSyncErrorMessageView: View { .padding([.leading, .trailing]) } Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing) - Button(Localization.actionTitle, action: handler) + Button(Localization.actionTitle, action: retryHandler) .buttonStyle(POSFilledButtonStyle(size: .normal)) .padding([.leading, .trailing], Constants.buttonSidePadding) .padding([.bottom], Constants.buttonBottomPadding) @@ -59,7 +59,6 @@ private extension PointOfSaleOrderSyncErrorMessageView { } } -private struct TestError: Error {} #Preview { PointOfSaleOrderSyncErrorMessageView(message: "An error happened!") {} } diff --git a/WooCommerce/Classes/POS/Presentation/TotalsView.swift b/WooCommerce/Classes/POS/Presentation/TotalsView.swift index 1ea379f9aae..714d230006d 100644 --- a/WooCommerce/Classes/POS/Presentation/TotalsView.swift +++ b/WooCommerce/Classes/POS/Presentation/TotalsView.swift @@ -76,10 +76,10 @@ struct TotalsView: View { } .animation(.default, value: isShowingPaymentView) case .error(.other(let message), let handler): - PointOfSaleOrderSyncErrorMessageView(message: message, handler: handler) + PointOfSaleOrderSyncErrorMessageView(message: message, retryHandler: handler) .transition(.opacity) case .error(.invalidCoupon(let message), let handler): - PointOfSaleOrderSyncErrorMessageView(message: message, handler: handler) + PointOfSaleOrderSyncCouponsErrorMessageView(message: message, retryHandler: handler) .transition(.opacity) } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index c5b9b400531..d2c36e294b1 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 019630B42D01DB4800219D80 /* TapToPayAwarenessMomentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */; }; 019630B62D02018C00219D80 /* TapToPayAwarenessMomentDeterminer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */; }; 019630B82D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */; }; + 01AAD8142D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AAD8132D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift */; }; 01ADC1362C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */; }; 01ADC1382C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */; }; 01B744E22D2FCA1400AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */; }; @@ -3286,6 +3287,7 @@ 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentView.swift; sourceTree = ""; }; 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminer.swift; sourceTree = ""; }; 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayAwarenessMomentDeterminerTests.swift; sourceTree = ""; }; + 01AAD8132D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderSyncCouponsErrorMessageView.swift; sourceTree = ""; }; 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift; sourceTree = ""; }; 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift; sourceTree = ""; }; 01B744E12D2FCA1300AEB3F4 /* PushNotificationBackgroundSynchronizerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationBackgroundSynchronizerFactory.swift; sourceTree = ""; }; @@ -6516,6 +6518,7 @@ 014BD4B62C64E26B0011A66E /* Order Messages */ = { isa = PBXGroup; children = ( + 01AAD8132D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift */, 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */, ); path = "Order Messages"; @@ -16624,6 +16627,7 @@ 0373A12F2A1D1F2100731236 /* DotView.swift in Sources */, 0295CDC02D6477C400865E27 /* POSNoticeView.swift in Sources */, DE279BA826E9C8E3002BA963 /* ShippingLabelSinglePackage.swift in Sources */, + 01AAD8142D92E37A0081D60B /* PointOfSaleOrderSyncCouponsErrorMessageView.swift in Sources */, 01F42C162CE34AB8003D0A5A /* CardPresentModalBuiltInSuccessEmailSent.swift in Sources */, 028FF8E32AA1E1C60038964F /* ProductDetailsCellViewModel+AddOns.swift in Sources */, DEC2962726C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift index db032509607..6a4c4729be1 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift @@ -60,6 +60,8 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { removeAllItemsFromCartCalled = true } + func removeAllCouponsFromCart() { } + func checkOut() async { } func addMoreToCart() { } From 9b70b7bba99618bd1d59e56d9ae35dd282156c1f Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 15:48:15 +0200 Subject: [PATCH 4/6] Support HTML error message for Coupons --- ...SaleOrderSyncCouponsErrorMessageView.swift | 19 ++++++++++++++++--- .../Classes/POS/Utils/POSFontStyle.swift | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift index ed8e1731c8f..b137de9e29d 100644 --- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift +++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift @@ -6,6 +6,21 @@ struct PointOfSaleOrderSyncCouponsErrorMessageView: View { let retryHandler: () -> Void @Environment(PointOfSaleAggregateModel.self) private var posModel + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + private var attributedMessage: AttributedString { + if let data = message.data(using: .utf8), + let nsAttributedString = try? NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil) { + var attributedString = AttributedString(nsAttributedString) + attributedString.font = POSFontStyle.posBodyLargeRegular().font() + attributedString.foregroundColor = UIColor(Color.posOnSurface) + return attributedString + } + return AttributedString(message) + } var body: some View { HStack(alignment: .center) { @@ -19,9 +34,7 @@ struct PointOfSaleOrderSyncCouponsErrorMessageView: View { .foregroundStyle(Color.posOnSurface) .font(.posHeadingBold) - Text(message) - .foregroundStyle(Color.posOnSurface) - .font(.posBodyLargeRegular()) + Text(attributedMessage) .padding([.leading, .trailing]) } Spacer().frame(height: PointOfSaleCardPresentPaymentLayout.textAndButtonSpacing) diff --git a/WooCommerce/Classes/POS/Utils/POSFontStyle.swift b/WooCommerce/Classes/POS/Utils/POSFontStyle.swift index 1a09cceb833..67dcf9ee86a 100644 --- a/WooCommerce/Classes/POS/Utils/POSFontStyle.swift +++ b/WooCommerce/Classes/POS/Utils/POSFontStyle.swift @@ -18,7 +18,7 @@ enum POSFontStyle { case posButtonSymbolMedium case posButtonSymbolLarge - fileprivate func font(maximumContentSizeCategory: UIContentSizeCategory? = nil) -> Font { + func font(maximumContentSizeCategory: UIContentSizeCategory? = nil) -> Font { switch self { case .posHeadingBold: Font.system(size: scaledValue(FontSize.heading, maximumContentSizeCategory: maximumContentSizeCategory ?? .accessibilityLarge), weight: .bold) From f5975724550a4c2464e8de1f93b01d6ca7ef56ae Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:06:29 +0200 Subject: [PATCH 5/6] Add PointOfSaleOrderControllerTests to confirm CouponsError handling --- .../PointOfSaleOrderControllerTests.swift | 89 +++++++++++++++++++ .../POS/Mocks/MockPOSOrderService.swift | 5 ++ 2 files changed, 94 insertions(+) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index 1c43ee8b6d3..92117c0c81e 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -9,6 +9,8 @@ import struct Yosemite.OrderCouponLine import enum Yosemite.OrderAction import class WooFoundation.CurrencySettings import protocol WooFoundation.Analytics +import enum Networking.DotcomError +import enum Networking.NetworkError struct PointOfSaleOrderControllerTests { let mockOrderService = MockPOSOrderService() @@ -435,6 +437,93 @@ struct PointOfSaleOrderControllerTests { #expect(mockOrderService.syncOrderWasCalled == true) } + @available(iOS 17.0, *) + @Test func syncOrder_when_orderService_fails_with_couponsError_then_sets_invalidCoupon_error() async throws { + // Given + let sut = PointOfSaleOrderController(orderService: mockOrderService, + receiptService: mockReceiptService) + let errorMessage = "Invalid coupon code" + mockOrderService.errorToReturn = DotcomError.unknown(code: "woocommerce_rest_invalid_coupon", message: errorMessage) + + var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] + var orderStateAppendTask: Task? = nil + await confirmation(expectedCount: 2) { confirmation in + @Sendable func observeOrderState() { + withObservationTracking { + _ = sut.orderState + } onChange: { + orderStateAppendTask = Task { @MainActor in + orderStates.append(sut.orderState) + } + confirmation() + observeOrderState() + } + } + observeOrderState() + + // When + await sut.syncOrder(for: .init(items: [makeItem()], + coupons: [.init(id: UUID(), code: "INVALID")]), + retryHandler: {}) + } + + await orderStateAppendTask?.value + + // Then + #expect(orderStates == [ + .idle, + .syncing, + .error(.invalidCoupon(errorMessage), {}) + ]) + } + + @available(iOS 17.0, *) + @Test func syncOrder_when_orderService_fails_with_networkError_containing_couponsError_then_sets_invalidCoupon_error() async throws { + // Given + let sut = PointOfSaleOrderController(orderService: mockOrderService, + receiptService: mockReceiptService) + let errorMessage = "Coupon INVALID does not exist" + let errorJSON = """ + { + "code": "woocommerce_rest_invalid_coupon", + "message": "\(errorMessage)" + } + """ + let errorData = errorJSON.data(using: .utf8)! + mockOrderService.errorToReturn = NetworkError.unacceptableStatusCode(statusCode: 400, response: errorData) + + var orderStates: [PointOfSaleInternalOrderState] = [sut.orderState] + var orderStateAppendTask: Task? = nil + await confirmation(expectedCount: 2) { confirmation in + @Sendable func observeOrderState() { + withObservationTracking { + _ = sut.orderState + } onChange: { + orderStateAppendTask = Task { @MainActor in + orderStates.append(sut.orderState) + } + confirmation() + observeOrderState() + } + } + observeOrderState() + + // When + await sut.syncOrder(for: .init(items: [makeItem()], + coupons: [.init(id: UUID(), code: "INVALID")]), + retryHandler: {}) + } + + await orderStateAppendTask?.value + + // Then + #expect(orderStates == [ + .idle, + .syncing, + .error(.invalidCoupon(errorMessage), {}) + ]) + } + struct AnalyticsTests { private let analytics: WooAnalytics private let analyticsProvider = MockAnalyticsProvider() diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift index 0a4d5332808..4987309bebb 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift @@ -5,6 +5,7 @@ import WooFoundation class MockPOSOrderService: POSOrderServiceProtocol { var simulateSyncing = false var orderToReturn: Order? + var errorToReturn: Error? var syncOrderWasCalled = false var updateOrderWasCalled = false @@ -20,6 +21,10 @@ class MockPOSOrderService: POSOrderServiceProtocol { try await Task.sleep(nanoseconds: UInt64(1 * Double(NSEC_PER_SEC))) } + if let error = errorToReturn { + throw error + } + guard let order = orderToReturn else { throw MockPOSOrderServiceError.noOrderToReturn } From 6376ff75c620e28833dc74d581db6b2cd804acf7 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Wed, 26 Mar 2025 10:28:36 +0200 Subject: [PATCH 6/6] Clarified TODO comment for PointOfSaleOrderSyncCouponsErrorMessageView --- .../PointOfSaleOrderSyncCouponsErrorMessageView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift index b137de9e29d..6b45d94dcfe 100644 --- a/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift +++ b/WooCommerce/Classes/POS/Presentation/Order Messages/PointOfSaleOrderSyncCouponsErrorMessageView.swift @@ -64,7 +64,7 @@ private extension PointOfSaleOrderSyncCouponsErrorMessageView { } } -// MARK: - TODO when copy is finalized +// MARK: - TODO https://github.com/woocommerce/woocommerce-ios/issues/15424 // //private extension PointOfSaleOrderSyncErrorMessageView { // enum Localization {