diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index e6178b75478..2bd928c6d31 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -94,6 +94,8 @@ steps: - label: ":microscope: UI Tests (iPhone)" command: .buildkite/commands/run-ui-tests.sh UITests 'iPhone 16' depends_on: build + # Only run on `trunk` and `release/*` -- See p91TBi-cBM-p2#comment-13736 + if: build.branch == 'trunk' || build.branch =~ /^release\// plugins: [$CI_TOOLKIT] artifact_paths: - fastlane/test_output/* @@ -104,6 +106,8 @@ steps: - label: ":microscope: UI Tests (iPad)" command: .buildkite/commands/run-ui-tests.sh UITests "iPad (10th generation)" depends_on: build + # Only run on `trunk` and `release/*` -- See p91TBi-cBM-p2#comment-13736 + if: build.branch == 'trunk' || build.branch =~ /^release\// plugins: [$CI_TOOLKIT] artifact_paths: - fastlane/test_output/* diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e9f70bbd9cd..02d9ca77394 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,6 +2,7 @@ Closes: # + ## Description diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index b69201f412e..41172b74c3f 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -18,14 +18,14 @@ jobs: steps: - name: "Check out Project" - uses: actions/checkout@v1 + uses: actions/checkout@v4 - name: "Set up Ruby" uses: ruby/setup-ruby@v1 with: bundler-cache: true - name: Restore CocoaPods Dependency Cache id: restore-cocoapods-dependency-cache - uses: actions/cache@v1 + uses: actions/cache@v4 with: path: Pods key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} @@ -37,13 +37,13 @@ jobs: run: bundle exec fastlane build_screenshots - name: Archive App - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: screenshot-app path: fastlane/DerivedData/Build/Products/Debug-iphonesimulator/WooCommerce.app - name: Archive Runner - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: screenshot-runner path: fastlane/DerivedData/Build/Products/Debug-iphonesimulator/WooCommerceScreenshots-Runner.app @@ -58,7 +58,7 @@ jobs: language: [ar, de-DE, en-US, es-ES, fr-FR, he, id, it, ja, ko, nl-NL, pt-BR, ru, sv, tr, zh-Hans, zh-Hant] mode: [dark, light] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: "Set up Ruby" uses: ruby/setup-ruby@v1 with: @@ -68,13 +68,13 @@ jobs: run: bundle exec fastlane run configure_apply - name: Download Screenshot App - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v4 with: name: screenshot-app path: fastlane/DerivedData/Build/Products/Debug-iphonesimulator/WooCommerce.app - name: Download Screenshot Runner - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v4 with: name: screenshot-runner path: fastlane/DerivedData/Build/Products/Debug-iphonesimulator/WooCommerceScreenshots-Runner.app @@ -85,7 +85,7 @@ jobs: - name: Store Logs if: always() - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: "screenshot-log-${{ matrix.language }}-${{ matrix.mode }}" path: fastlane/logs @@ -101,7 +101,7 @@ jobs: runs-on: macos-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Install Native Dependencies run: | @@ -130,7 +130,7 @@ jobs: aws s3 cp fastlane/screenshots/screenshots.html s3://$S3_BUCKET/$GITHUB_RUN_ID/screenshots/screenshots.html - name: Archive Raw Screenshots - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: raw-screenshots path: fastlane/screenshots @@ -159,7 +159,7 @@ jobs: bundle exec fastlane create_promo_screenshots force:true - name: Archive Promo Screenshots - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: promo-screenshots path: fastlane/promo_screenshots diff --git a/CHANGELOG.md b/CHANGELOG.md index 827273b44f3..2ca012408c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ +## 21.3 + +This update brings enhanced reliability and clarity to your WooCommerce experience! Enjoy improved Jetpack setup, smoother media handling, and better product and payment workflows. We’ve also optimized storage and addressed key UI issues to elevate performance. Plus, Tap to Pay onboarding now guides you with ease! + ## 21.2 In just two weeks, we've jam-packed this release. There's GTIN global product identifier support, and you can edit the call-to-action in Blaze campaigns. diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index 390a547b0ab..9777657daa5 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -89,14 +89,20 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .productGlobalUniqueIdentifierSupport: return true - case .paymentsOnboardingInPointOfSale: - return true case .sendReceiptAfterPayment: - return false + return true case .sendReceiptsForPointOfSale: + return true + case .acceptCashForPointOfSale: return false case .tapToPayEducation: - return false + return true + case .variableProductsInPointOfSale: + return buildConfig == .localDeveloper || buildConfig == .alpha + case .hideSitesInStorePicker: + return true + case .filterHistoryOnOrderAndProductLists: + return buildConfig == .localDeveloper || buildConfig == .alpha default: return true } diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index 9f102b0b568..d0c700f69ce 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -193,10 +193,6 @@ public enum FeatureFlag: Int { /// case productGlobalUniqueIdentifierSupport - /// Supports Woo Payments onboarding in POS so that merchants who have not completed onboarding can access POS. - /// - case paymentsOnboardingInPointOfSale - /// Enables sending receipt after the payment via the API case sendReceiptAfterPayment @@ -204,7 +200,23 @@ public enum FeatureFlag: Int { /// case sendReceiptsForPointOfSale + /// Adds support for accepting cash as payment for POS + /// + case acceptCashForPointOfSale + /// Enables new Tap to Pay onboarding and education features /// case tapToPayEducation + + /// Supports variable products in POS. + /// + case variableProductsInPointOfSale + + /// Supports hiding sites from the store picker + /// + case hideSitesInStorePicker + + /// Supports managing filer history on order and product lists + /// + case filterHistoryOnOrderAndProductLists } diff --git a/Fakes/Fakes/Fake.swift b/Fakes/Fakes/Fake.swift index 5020e0d27c6..d83415fb4ec 100644 --- a/Fakes/Fakes/Fake.swift +++ b/Fakes/Fakes/Fake.swift @@ -119,3 +119,10 @@ extension NSRange { .init() } } + +extension UUID { + /// Returns a default UUID + static func fake() -> Self { + .init() + } +} diff --git a/Fakes/Fakes/Networking.generated.swift b/Fakes/Fakes/Networking.generated.swift index a7a2d3b8b25..15ccac9bba7 100644 --- a/Fakes/Fakes/Networking.generated.swift +++ b/Fakes/Fakes/Networking.generated.swift @@ -2761,6 +2761,16 @@ extension Networking.WooShippingAccountSettings { ) } } +extension Networking.WooShippingCarrierPredefinedOptions { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Networking.WooShippingCarrierPredefinedOptions { + .init( + carrierID: .fake(), + predefinedOptions: .fake() + ) + } +} extension Networking.WooShippingCreatePackageResponse { /// Returns a "ready to use" type filled with fake values. /// @@ -2784,6 +2794,28 @@ extension Networking.WooShippingCustomPackage { ) } } +extension Networking.WooShippingOriginAddress { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Networking.WooShippingOriginAddress { + .init( + id: .fake(), + company: .fake(), + address1: .fake(), + address2: .fake(), + city: .fake(), + state: .fake(), + postcode: .fake(), + country: .fake(), + phone: .fake(), + firstName: .fake(), + lastName: .fake(), + email: .fake(), + defaultAddress: .fake(), + isVerified: .fake() + ) + } +} extension Networking.WooShippingPackagePurchase { /// Returns a "ready to use" type filled with fake values. /// @@ -2801,7 +2833,7 @@ extension Networking.WooShippingPackagesResponse { /// public static func fake() -> Networking.WooShippingPackagesResponse { .init( - storeOptions: .fake(), + siteID: .fake(), customPackages: .fake(), savedPredefinedPackages: .fake(), allPredefinedOptions: .fake() diff --git a/Fakes/Fakes/Yosemite.generated.swift b/Fakes/Fakes/Yosemite.generated.swift index 7eede1cf990..15783857064 100644 --- a/Fakes/Fakes/Yosemite.generated.swift +++ b/Fakes/Fakes/Yosemite.generated.swift @@ -33,6 +33,22 @@ extension Yosemite.JustInTimeMessageTemplate { .banner } } +extension Yosemite.POSSimpleProduct { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Yosemite.POSSimpleProduct { + .init( + id: .fake(), + name: .fake(), + formattedPrice: .fake(), + productImageSource: .fake(), + productID: .fake(), + price: .fake(), + productType: .fake(), + bundledItems: .fake() + ) + } +} extension Yosemite.ProductReviewFromNoteParcel { /// Returns a "ready to use" type filled with fake values. /// diff --git a/Hardware/Hardware/CardReader/CardReaderService.swift b/Hardware/Hardware/CardReader/CardReaderService.swift index 5696800cf72..21833e90bbb 100644 --- a/Hardware/Hardware/CardReader/CardReaderService.swift +++ b/Hardware/Hardware/CardReader/CardReaderService.swift @@ -16,6 +16,9 @@ public protocol CardReaderService { /// The Publisher that emits software update state changes var softwareUpdateEvents: AnyPublisher { get } + /// The Publisher that emits when TTP Terms and Services are accepted + var builtInCardReaderAcceptToSEvents: AnyPublisher { get } + // MARK: - Commands /// Checks for support of a given reader type and discovery method combination. Does not start discovery. diff --git a/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift index 485bd1b62e4..5e01db0863f 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/NoOpCardReaderService.swift @@ -15,6 +15,10 @@ public struct NoOpCardReaderService: CardReaderService { public var softwareUpdateEvents: AnyPublisher = CurrentValueSubject(.none).eraseToAnyPublisher() + /// The Publisher that emits the when when TTP Terms and Services are accepted + public var builtInCardReaderAcceptToSEvents: AnyPublisher + = PassthroughSubject().eraseToAnyPublisher() + public init() {} // MARK: - Commands diff --git a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift index 6c23d2108d5..d069ee3da3c 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/StripeCardReaderService.swift @@ -16,6 +16,8 @@ public final class StripeCardReaderService: NSObject { private let discoveryStatusSubject = CurrentValueSubject(.idle) private let readerEventsSubject = PassthroughSubject() private let softwareUpdateSubject = CurrentValueSubject(.none) + private let builtInCardReaderAcceptToSSubject = PassthroughSubject() + private var connectionAttemptInvalidated: Bool = false /// Volatile, in-memory cache of discovered readers. It has to be cleared after we connect to a reader @@ -62,6 +64,10 @@ extension StripeCardReaderService: CardReaderService { softwareUpdateSubject.eraseToAnyPublisher() } + public var builtInCardReaderAcceptToSEvents: AnyPublisher { + builtInCardReaderAcceptToSSubject.eraseToAnyPublisher() + } + // MARK: - CardReaderService conformance. Commands public func checkSupport(for cardReaderType: CardReaderType, @@ -989,6 +995,10 @@ extension StripeCardReaderService: LocalMobileReaderDelegate { softwareUpdateSubject.send(.none) } } + + public func localMobileReaderDidAcceptTermsOfService(_ reader: Reader) { + builtInCardReaderAcceptToSSubject.send(()) + } } // MARK: - Terminal delegate diff --git a/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift b/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift index 92727f1444a..f7d6d959447 100644 --- a/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift +++ b/Hardware/Hardware/CardReader/StripeCardReader/UnderlyingError+Stripe.swift @@ -10,114 +10,239 @@ extension UnderlyingError { return nil } - switch error.code { - case ErrorCode.Code.notConnectedToReader.rawValue: + guard let stripeError = StripeTerminal.ErrorCode.Code(rawValue: error.code) else { + return nil + } + + switch stripeError { + case .notConnectedToReader: self = .notConnectedToReader - case ErrorCode.Code.alreadyConnectedToReader.rawValue: + case .alreadyConnectedToReader: self = .alreadyConnectedToReader - case ErrorCode.Code.confirmInvalidPaymentIntent.rawValue: + case .confirmInvalidPaymentIntent: self = .confirmInvalidPaymentIntent - case ErrorCode.Code.unsupportedSDK.rawValue: + case .unsupportedSDK: self = .unsupportedSDK - case ErrorCode.Code.featureNotAvailableWithConnectedReader.rawValue: + case .featureNotAvailableWithConnectedReader: self = .featureNotAvailableWithConnectedReader - case ErrorCode.Code.canceled.rawValue: + case .canceled: self = .commandCancelled(from: .unknown) - case ErrorCode.Code.locationServicesDisabled.rawValue: + case .locationServicesDisabled: self = .locationServicesDisabled - case ErrorCode.Code.bluetoothDisabled.rawValue: + case .bluetoothDisabled: self = .bluetoothDisabled - case ErrorCode.Code.bluetoothError.rawValue: + case .bluetoothError: self = .bluetoothError - case ErrorCode.Code.bluetoothScanTimedOut.rawValue: + case .bluetoothScanTimedOut: self = .bluetoothScanTimedOut - case ErrorCode.Code.bluetoothLowEnergyUnsupported.rawValue: + case .bluetoothLowEnergyUnsupported: self = .bluetoothLowEnergyUnsupported - case ErrorCode.Code.bluetoothConnectionFailedBatteryCriticallyLow.rawValue: + case .bluetoothConnectionFailedBatteryCriticallyLow: self = .bluetoothConnectionFailedBatteryCriticallyLow - case ErrorCode.Code.readerSoftwareUpdateFailedBatteryLow.rawValue: + case .readerSoftwareUpdateFailedBatteryLow: self = .readerSoftwareUpdateFailedBatteryLow - case ErrorCode.Code.readerSoftwareUpdateFailedInterrupted.rawValue: + case .readerSoftwareUpdateFailedInterrupted: self = .readerSoftwareUpdateFailedInterrupted - case ErrorCode.Code.readerSoftwareUpdateFailed.rawValue: + case .readerSoftwareUpdateFailed: self = .readerSoftwareUpdateFailed - case ErrorCode.Code.readerSoftwareUpdateFailedReaderError.rawValue: + case .readerSoftwareUpdateFailedReaderError: self = .readerSoftwareUpdateFailedReader - case ErrorCode.Code.readerSoftwareUpdateFailedServerError.rawValue: + case .readerSoftwareUpdateFailedServerError: self = .readerSoftwareUpdateFailedServer - case ErrorCode.Code.cardInsertNotRead.rawValue: + case .cardInsertNotRead: self = .cardInsertNotRead - case ErrorCode.Code.cardSwipeNotRead.rawValue: + case .cardSwipeNotRead: self = .cardSwipeNotRead - case ErrorCode.Code.cardReadTimedOut.rawValue: + case .cardReadTimedOut: self = .cardReadTimeOut - case ErrorCode.Code.cardRemoved.rawValue: + case .cardRemoved: self = .cardRemoved - case ErrorCode.Code.cardLeftInReader.rawValue: + case .cardLeftInReader: self = .cardLeftInReader - case ErrorCode.Code.readerBusy.rawValue: + case .readerBusy: self = .readerBusy - case ErrorCode.Code.incompatibleReader.rawValue: + case .incompatibleReader: self = .readerIncompatible - case ErrorCode.Code.readerCommunicationError.rawValue: + case .readerCommunicationError: self = .readerCommunicationError - case ErrorCode.Code.bluetoothConnectTimedOut.rawValue: + case .bluetoothConnectTimedOut: self = .bluetoothConnectTimedOut - case ErrorCode.Code.bluetoothDisconnected.rawValue: + case .bluetoothDisconnected: self = .bluetoothDisconnected - case ErrorCode.Code.bluetoothAccessDenied.rawValue: - self = .bluetoothDenied - case ErrorCode.Code.unsupportedReaderVersion.rawValue: + case .unsupportedReaderVersion: self = .unsupportedReaderVersion - case ErrorCode.Code.connectFailedReaderIsInUse.rawValue: + case .connectFailedReaderIsInUse: self = .connectFailedReaderIsInUse - case ErrorCode.Code.unexpectedSdkError.rawValue: + case .unexpectedSdkError: self = .unexpectedSDKError - case ErrorCode.Code.declinedByStripeAPI.rawValue: + case .declinedByStripeAPI: // https://stripe.dev/stripe-terminal-ios/docs/Errors.html#/c:@SCPErrorKeyStripeAPIDeclineCode let declineCode = error.userInfo[ErrorKey.stripeAPIDeclineCode.rawValue] as? String let declineReason = DeclineReason(with: declineCode ?? "") self = .paymentDeclinedByPaymentProcessorAPI(declineReason: declineReason) - case ErrorCode.Code.declinedByReader.rawValue: + case .declinedByReader: self = .paymentDeclinedByCardReader - case ErrorCode.Code.notConnectedToInternet.rawValue: + case .notConnectedToInternet: self = .notConnectedToInternet - case ErrorCode.Code.requestTimedOut.rawValue: + case .requestTimedOut: self = .requestTimedOut - case ErrorCode.Code.sessionExpired.rawValue: + case .sessionExpired: self = .readerSessionExpired - case ErrorCode.Code.stripeAPIError.rawValue: + case .stripeAPIError: self = .processorAPIError - case ErrorCode.Code.passcodeNotEnabled.rawValue: + case .passcodeNotEnabled: self = .passcodeNotEnabled - case ErrorCode.Code.appleBuiltInReaderTOSAcceptanceRequiresiCloudSignIn.rawValue: + case .appleBuiltInReaderTOSAcceptanceRequiresiCloudSignIn: self = .appleBuiltInReaderTOSAcceptanceRequiresiCloudSignIn - case ErrorCode.Code.nfcDisabled.rawValue: + case .nfcDisabled: self = .nfcDisabled - case ErrorCode.Code.appleBuiltInReaderFailedToPrepare.rawValue: + case .appleBuiltInReaderFailedToPrepare: self = .appleBuiltInReaderFailedToPrepare - case ErrorCode.Code.appleBuiltInReaderTOSAcceptanceCanceled.rawValue: + case .appleBuiltInReaderTOSAcceptanceCanceled: self = .appleBuiltInReaderTOSAcceptanceCanceled - case ErrorCode.Code.appleBuiltInReaderTOSNotYetAccepted.rawValue: + case .appleBuiltInReaderTOSNotYetAccepted: self = .appleBuiltInReaderTOSNotYetAccepted - case ErrorCode.Code.appleBuiltInReaderTOSAcceptanceFailed.rawValue: + case .appleBuiltInReaderTOSAcceptanceFailed: self = .appleBuiltInReaderTOSAcceptanceFailed - case ErrorCode.Code.appleBuiltInReaderMerchantBlocked.rawValue: + case .appleBuiltInReaderMerchantBlocked: self = .appleBuiltInReaderMerchantBlocked - case ErrorCode.Code.appleBuiltInReaderInvalidMerchant.rawValue: + case .appleBuiltInReaderInvalidMerchant: self = .appleBuiltInReaderInvalidMerchant - case ErrorCode.Code.appleBuiltInReaderDeviceBanned.rawValue: + case .appleBuiltInReaderDeviceBanned: self = .appleBuiltInReaderDeviceBanned - case ErrorCode.Code.unsupportedMobileDeviceConfiguration.rawValue: + case .unsupportedMobileDeviceConfiguration: self = .unsupportedMobileDeviceConfiguration - case ErrorCode.Code.readerNotAccessibleInBackground.rawValue: + case .readerNotAccessibleInBackground: self = .readerNotAccessibleInBackground - case ErrorCode.Code.commandNotAllowedDuringCall.rawValue: + case .commandNotAllowedDuringCall: self = .commandNotAllowedDuringCall - case ErrorCode.Code.invalidAmount.rawValue: + case .invalidAmount: self = .invalidAmount - case ErrorCode.Code.invalidCurrency.rawValue: + case .invalidCurrency: self = .invalidCurrency + case .cancelFailedAlreadyCompleted: + self = .cancelFailedAlreadyCompleted + case .nilPaymentIntent: + self = .nilPaymentIntent + case .nilSetupIntent: + self = .nilSetupIntent + case .nilRefundPaymentMethod: + self = .nilRefundPaymentMethod + case .invalidRefundParameters: + self = .invalidRefundParameters + case .invalidClientSecret: + self = .invalidClientSecret + case .invalidDiscoveryConfiguration: + self = .invalidDiscoveryConfiguration + case .invalidReaderForUpdate: + self = .invalidReaderForUpdate + case .featureNotAvailable: + self = .featureNotAvailable + case .bluetoothConnectionInvalidLocationIdParameter: + self = .bluetoothConnectionInvalidLocationIdParameter + case .invalidRequiredParameter: + self = .invalidRequiredParameter + case .forwardingTestModePaymentInLiveMode: + self = .forwardingTestModePaymentInLiveMode + case .forwardingLiveModePaymentInTestMode: + self = .forwardingLiveModePaymentInTestMode + case .readerConnectionConfigurationInvalid: + self = .readerConnectionConfigurationInvalid + case .readerTippingParameterInvalid: + self = .readerTippingParameterInvalid + case .invalidLocationIdParameter: + self = .invalidLocationIdParameter + case .bluetoothAccessDenied: + self = .bluetoothDenied + case .readerSoftwareUpdateFailedExpiredUpdate: + self = .readerSoftwareUpdateFailedExpiredUpdate + case .missingEMVData: + self = .missingEMVData + case .commandNotAllowed: + self = .commandNotAllowed + case .bluetoothPeerRemovedPairingInformation: + self = .bluetoothPeerRemovedPairingInformation + case .bluetoothAlreadyPairedWithAnotherDevice: + self = .bluetoothAlreadyPairedWithAnotherDevice + case .unknownReaderIpAddress: + self = .unknownReaderIpAddress + case .internetConnectTimeOut: + self = .internetConnectTimeOut + case .bluetoothReconnectStarted: + self = .bluetoothReconnectStarted + case .appleBuiltInReaderAccountDeactivated: + self = .appleBuiltInReaderAccountDeactivated + case .readerMissingEncryptionKeys: + self = .readerMissingEncryptionKeys + case .unexpectedReaderError: + self = .unexpectedReaderError + case .commandRequiresCardholderConsent: + self = .commandRequiresCardholderConsent + case .refundFailed: + self = .refundFailed + case .cardSwipeNotAvailable: + self = .cardSwipeNotAvailable + case .interacNotSupportedOffline: + self = .interacNotSupportedOffline + case .offlineAndCardExpired: + self = .offlineAndCardExpired + case .offlineTransactionDeclined: + self = .offlineTransactionDeclined + case .offlineCollectAndConfirmMismatch: + self = .offlineCollectAndConfirmMismatch + case .onlinePinNotSupportedOffline: + self = .onlinePinNotSupportedOffline + case .offlineTestCardInLivemode: + self = .offlineTestCardInLivemode + case .stripeAPIResponseDecodingError: + self = .stripeAPIResponseDecodingError + case .internalNetworkError: + self = .internalNetworkError + case .connectionTokenProviderCompletedWithError: + self = .connectionTokenProviderCompletedWithError + case .connectionTokenProviderTimedOut: + self = .connectionTokenProviderTimedOut + /// Our `DefaultConnectionTokenProvider` implementation of `fetchConnectionToken` never calls completion block with `(nil, nil)`. + case .connectionTokenProviderCompletedWithNothing, + /// Offline mode is not supported. + .connectionTokenProviderCompletedWithNothingWhileForwarding, + .accountIdMismatchWhileForwarding, + .updatePaymentIntentUnavailableWhileOffline, + .updatePaymentIntentUnavailableWhileOfflineModeEnabled, + .offlinePaymentsDatabaseTooLarge, + .readerConnectionOfflinePairingUnseenDisabled, + .noLastSeenAccount, + .connectionTokenProviderCompletedWithErrorWhileForwarding, + .offlineBehaviorForceOfflineWithFeatureDisabled, + .readerConnectionNotAvailableOffline, + .readerConnectionOfflineLocationMismatch, + .readerConnectionOfflineNeedsUpdate, + .amountExceedsMaxOfflineAmount, + .invalidOfflineCurrency, + .encryptionKeyFailure, + .encryptionKeyStillInitializing, + .notConnectedToInternetAndOfflineBehaviorRequireOnline, + /// We don’t request a list of locations directly, but request the store location instead. + .invalidListLocationsLimitParameter, + /// `on_behalf_of` parameter is not set in the payment intent. + .invalidRequiredParameterOnBehalfOf, + /// Dynamic currency conversion not supported. + .requestDynamicCurrencyConversionRequiresUpdatePaymentIntent, + .dynamicCurrencyConversionNotAvailable, + /// Surcharging is not supported. + .surchargingNotAvailable, + .surchargeNoticeRequiresUpdatePaymentIntent, + .surchargeUnavailableWithDynamicCurrencyConversion, + /// Collecting on-screen inputs from card reader is not supported. + .collectInputsInvalidParameter, + .collectInputsUnsupported, + .collectInputsTimedOut, + .collectInputsApplicationError, + /// USB discovery is not supported. + .usbDiscoveryTimedOut, + .usbDisconnected: + assertionFailure("Unexpected Stripe error that we should consider handling: \(stripeError)") + return nil default: return nil } diff --git a/Hardware/Hardware/CardReader/UnderlyingError.swift b/Hardware/Hardware/CardReader/UnderlyingError.swift index 7dc422a3638..611530e4c6c 100644 --- a/Hardware/Hardware/CardReader/UnderlyingError.swift +++ b/Hardware/Hardware/CardReader/UnderlyingError.swift @@ -218,6 +218,166 @@ public enum UnderlyingError: Error, Equatable { /// The reader may support a different set of currencies than WCPay or Stripe. /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidCurrency case invalidCurrency + + /// The operation could not be canceled because it was already completed. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorCancelFailedAlreadyCompleted + case cancelFailedAlreadyCompleted + + /// The payment intent is missing. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorNilPaymentIntent + case nilPaymentIntent + + /// The setup intent is missing. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorNilSetupIntent + case nilSetupIntent + + /// The refund payment method is missing. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorNilRefundPaymentMethod + case nilRefundPaymentMethod + + /// The refund parameters are invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidRefundParameters + case invalidRefundParameters + + /// The client secret is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidClientSecret + case invalidClientSecret + + /// The discovery configuration is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidDiscoveryConfiguration + case invalidDiscoveryConfiguration + + /// The reader for update is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidReaderForUpdate + case invalidReaderForUpdate + + /// The feature is unavailable. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorFeatureNotAvailable + case featureNotAvailable + + /// The Bluetooth connection has an invalid location ID parameter. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorBluetoothConnectionInvalidLocationIdParameter + case bluetoothConnectionInvalidLocationIdParameter + + /// A required parameter is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidRequiredParameter + case invalidRequiredParameter + + /// Forwarding a test mode payment in live mode is prohibited. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorForwardingTestModePaymentInLiveMode + case forwardingTestModePaymentInLiveMode + + /// Forwarding a live mode payment in test mode is prohibited. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorForwardingLiveModePaymentInTestMode + case forwardingLiveModePaymentInTestMode + + /// The reader connection configuration is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorReaderConnectionConfigurationInvalid + case readerConnectionConfigurationInvalid + + /// The reader tipping parameter is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorReaderTippingParameterInvalid + case readerTippingParameterInvalid + + /// The location ID parameter is invalid. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInvalidLocationIdParameter + case invalidLocationIdParameter + + /// The reader software update failed due to an expired update. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorReaderSoftwareUpdateFailedExpiredUpdate + case readerSoftwareUpdateFailedExpiredUpdate + + /// EMV data is missing. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorMissingEMVData + case missingEMVData + + /// The command is not allowed. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorCommandNotAllowed + case commandNotAllowed + + /// The Bluetooth peer removed pairing information. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorBluetoothPeerRemovedPairingInformation + case bluetoothPeerRemovedPairingInformation + + /// Bluetooth is already paired with another device. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorBluetoothAlreadyPairedWithAnotherDevice + case bluetoothAlreadyPairedWithAnotherDevice + + /// The reader's IP address is unknown. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorUnknownReaderIpAddress + case unknownReaderIpAddress + + /// Internet connection operation timed out. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInternetConnectTimeOut + case internetConnectTimeOut + + /// Bluetooth reconnect has started. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorBluetoothReconnectStarted + case bluetoothReconnectStarted + + /// The Apple built-in reader account is deactivated. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorTapToPayReaderAccountDeactivated + case appleBuiltInReaderAccountDeactivated + + /// The reader is missing encryption keys. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorReaderMissingEncryptionKeys + case readerMissingEncryptionKeys + + /// An unexpected error occurred with the reader. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorUnexpectedReaderError + case unexpectedReaderError + + /// The command requires cardholder consent. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorCommandRequiresCardholderConsent + case commandRequiresCardholderConsent + + /// The refund operation failed. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorRefundFailed + case refundFailed + + /// Card swipe functionality is unavailable. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorCardSwipeNotAvailable + case cardSwipeNotAvailable + + /// Interac is not supported in offline mode. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInteracNotSupportedOffline + case interacNotSupportedOffline + + /// The card is expired and offline mode is active. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorOfflineAndCardExpired + case offlineAndCardExpired + + /// The offline transaction was declined. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorOfflineTransactionDeclined + case offlineTransactionDeclined + + /// There is a mismatch between offline collect and confirm. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorOfflineCollectAndConfirmMismatch + case offlineCollectAndConfirmMismatch + + /// Online PIN is not supported in offline mode. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorOnlinePinNotSupportedOffline + case onlinePinNotSupportedOffline + + /// A test card is used in live mode while offline. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorOfflineTestCardInLivemode + case offlineTestCardInLivemode + + /// There is an error decoding the Stripe API response. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorStripeAPIResponseDecodingError + case stripeAPIResponseDecodingError + + /// An internal network error occurred. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorInternalNetworkError + case internalNetworkError + + /// The connection token provider finished with an error. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorConnectionTokenProviderCompletedWithError + case connectionTokenProviderCompletedWithError + + /// The connection token provider operation timed out. + /// https://stripe.dev/stripe-terminal-ios/docs/Enums/SCPError.html#/c:@E@SCPError@SCPErrorConnectionTokenProviderTimedOut + case connectionTokenProviderTimedOut } extension UnderlyingError { @@ -334,6 +494,7 @@ extension UnderlyingError: LocalizedError { "You can grant permission in the system's Settings app, in the Woo section.", comment: "Explanation in the alert presented when the user tries to connect a Bluetooth card reader with insufficient permissions" ) + case .readerSoftwareUpdateFailedBatteryLow: return NSLocalizedString("Unable to update card reader software - the reader battery is too low.", comment: "Error message when the card reader battery level is too low to safely perform a software update.") @@ -528,6 +689,291 @@ extension UnderlyingError: LocalizedError { "reader or another payment method.", comment: "Error message shown when Tap to Pay on iPhone cannot be used because " + "the currency for payment is not supported for Tap to Pay on iPhone.") + + case .cancelFailedAlreadyCompleted: + return NSLocalizedString( + "hardware.cardReader.underlyingError.cancelFailedAlreadyCompleted", + value: "The operation could not be canceled because it was already completed.", + comment: "Error message when an operation cannot be canceled because it is already completed." + ) + + case .nilPaymentIntent: + return NSLocalizedString( + "hardware.cardReader.underlyingError.nilPaymentIntent", + value: "Please contact support - the payment intent is missing.", + comment: "Error message when the payment intent is missing." + ) + + case .nilSetupIntent: + return NSLocalizedString( + "hardware.cardReader.underlyingError.nilSetupIntent", + value: "Please contact support - the setup intent is missing.", + comment: "Error message when the setup intent is missing." + ) + + case .nilRefundPaymentMethod: + return NSLocalizedString( + "hardware.cardReader.underlyingError.nilRefundPaymentMethod", + value: "Please contact support - the refund payment method is missing.", + comment: "Error message when the refund payment method is missing." + ) + + case .invalidRefundParameters: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidRefundParameters", + value: "Please contact support - the refund parameters are invalid.", + comment: "Error message when the refund parameters are invalid." + ) + + case .invalidClientSecret: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidClientSecret", + value: "Please contact support - the client secret is invalid.", + comment: "Error message when the client secret is invalid." + ) + + case .invalidDiscoveryConfiguration: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidDiscoveryConfiguration", + value: "Please contact support - the discovery configuration is invalid.", + comment: "Error message when the discovery configuration is invalid." + ) + + case .invalidReaderForUpdate: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidReaderForUpdate", + value: "The reader for update is invalid.", + comment: "Error message when the reader for update is invalid." + ) + + case .featureNotAvailable: + return NSLocalizedString( + "hardware.cardReader.underlyingError.featureNotAvailable", + value: "Please contact support - the feature is unavailable.", + comment: "Error message when a feature is unavailable." + ) + + case .bluetoothConnectionInvalidLocationIdParameter: + return NSLocalizedString( + "hardware.cardReader.underlyingError.bluetoothConnectionInvalidLocationIdParameter", + value: "The Bluetooth connection has an invalid location ID.", + comment: "Error message when the Bluetooth connection has an invalid location ID parameter." + ) + + case .invalidRequiredParameter: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidRequiredParameter", + value: "Please contact support - a required parameter is invalid.", + comment: "Error message when a required parameter is invalid." + ) + + case .forwardingTestModePaymentInLiveMode: + return NSLocalizedString( + "hardware.cardReader.underlyingError.forwardingTestModePaymentInLiveMode", + value: "Forwarding a test mode payment in live mode is prohibited.", + comment: "Error message when forwarding a test mode payment in live mode is prohibited." + ) + + case .forwardingLiveModePaymentInTestMode: + return NSLocalizedString( + "hardware.cardReader.underlyingError.forwardingLiveModePaymentInTestMode", + value: "Forwarding a live mode payment in test mode is prohibited.", + comment: "Error message when forwarding a live mode payment in test mode is prohibited." + ) + + case .readerConnectionConfigurationInvalid: + return NSLocalizedString( + "hardware.cardReader.underlyingError.readerConnectionConfigurationInvalid", + value: "The reader connection configuration is invalid.", + comment: "Error message when the reader connection configuration is invalid." + ) + + case .readerTippingParameterInvalid: + return NSLocalizedString( + "hardware.cardReader.underlyingError.readerTippingParameterInvalid", + value: "Please contact support - the reader tipping parameter is invalid.", + comment: "Error message when the reader tipping parameter is invalid." + ) + + case .invalidLocationIdParameter: + return NSLocalizedString( + "hardware.cardReader.underlyingError.invalidLocationIdParameter", + value: "The location ID is invalid.", + comment: "Error message when the location ID parameter is invalid." + ) + + case .readerSoftwareUpdateFailedExpiredUpdate: + return NSLocalizedString( + "hardware.cardReader.underlyingError.readerSoftwareUpdateFailedExpiredUpdate", + value: "Updating the reader software failed because the update has expired. " + + "Please disconnect and reconnect from the reader to retrieve a new update.", + comment: "Error message when the reader software update fails due to an expired update." + ) + + case .missingEMVData: + return NSLocalizedString( + "hardware.cardReader.underlyingError.missingEMVData", + value: "The reader failed to read the data from the presented payment method. " + + "If you encounter this error repeatedly, the reader may be faulty and please contact support.", + comment: "Error message when EMV data is missing." + ) + + case .commandNotAllowed: + return NSLocalizedString( + "hardware.cardReader.underlyingError.commandNotAllowed", + value: "Please contact support - the command is not allowed to execute by the operating system.", + comment: "Error message when the command is not allowed." + ) + + case .bluetoothPeerRemovedPairingInformation: + return NSLocalizedString( + "hardware.cardReader.underlyingError.bluetoothPeerRemovedPairingInformation", + value: "The reader has removed this device pairing information. Try forgetting the reader in iOS Settings.", + comment: "Error message when the Bluetooth peer removed pairing information." + ) + + case .bluetoothAlreadyPairedWithAnotherDevice: + return NSLocalizedString( + "hardware.cardReader.underlyingError.bluetoothAlreadyPairedWithAnotherDevice", + value: "The Bluetooth reader is already paired to another device. The reader must have its pairing reset to connect to this device.", + comment: "Error message when Bluetooth is already paired with another device." + ) + + case .unknownReaderIpAddress: + return NSLocalizedString( + "hardware.cardReader.underlyingError.unknownReaderIpAddress", + value: "The reader returned from discovery does not have an IP address and cannot be connected to.", + comment: "Error message when the reader's IP address is unknown." + ) + + case .internetConnectTimeOut: + return NSLocalizedString( + "hardware.cardReader.underlyingError.internetConnectTimeOut", + value: "Connecting to reader over the internet timed out. " + + "Make sure your device and reader are on the same Wifi network and your reader is connected to the Wifi network.", + comment: "Error message when the internet connection operation timed out." + ) + + case .bluetoothReconnectStarted: + return NSLocalizedString( + "hardware.cardReader.underlyingError.bluetoothReconnectStarted", + value: "The Bluetooth reader has disconnected and we are attempting to reconnect.", + comment: "Error message when Bluetooth reconnect has started." + ) + + case .appleBuiltInReaderAccountDeactivated: + return NSLocalizedString( + "hardware.cardReader.underlyingError.appleBuiltInReaderAccountDeactivated", + value: "The linked Apple ID account has been deactivated.", + comment: "Error message when the Apple built-in reader account is deactivated." + ) + + case .readerMissingEncryptionKeys: + return NSLocalizedString( + "hardware.cardReader.underlyingError.readerMissingEncryptionKeys", + value: "The reader is missing encryption keys required for taking payments and has disconnected and rebooted. " + + "Reconnect to the reader to attempt to re-install the keys. If the error persists, please contact support.", + comment: "Error message when the reader is missing encryption keys." + ) + + case .unexpectedReaderError: + return NSLocalizedString( + "hardware.cardReader.underlyingError.unexpectedReaderError", + value: "An unexpected error occurred with the reader.", + comment: "Error message when an unexpected error occurs with the reader." + ) + + case .commandRequiresCardholderConsent: + return NSLocalizedString( + "hardware.cardReader.underlyingError.commandRequiresCardholderConsent", + value: "The cardholder must give consent in order for this operation to succeed.", + comment: "Error message when the command requires cardholder consent." + ) + + case .refundFailed: + return NSLocalizedString( + "hardware.cardReader.underlyingError.refundFailed", + value: "The refund failed. The customer’s bank or card issuer was unable to process it correctly " + + "(e.g., a closed bank account or a problem with the card).", + comment: "Error message when the refund operation failed." + ) + + case .cardSwipeNotAvailable: + return NSLocalizedString( + "hardware.cardReader.underlyingError.cardSwipeNotAvailable", + value: "Card swipe functionality is unavailable.", + comment: "Error message when card swipe functionality is unavailable." + ) + + case .interacNotSupportedOffline: + return NSLocalizedString( + "hardware.cardReader.underlyingError.interacNotSupportedOffline", + value: "Interac is not supported in offline mode.", + comment: "Error message when Interac is not supported in offline mode." + ) + + case .offlineAndCardExpired: + return NSLocalizedString( + "hardware.cardReader.underlyingError.offlineAndCardExpired", + value: "Confirming a payment while offline and the card was identified as being expired.", + comment: "Error message when the card is expired and offline mode is active." + ) + + case .offlineTransactionDeclined: + return NSLocalizedString( + "hardware.cardReader.underlyingError.offlineTransactionDeclined", + value: "Confirming a payment while offline and the card’s verification failed.", + comment: "Error message when the offline transaction was declined." + ) + + case .offlineCollectAndConfirmMismatch: + return NSLocalizedString( + "hardware.cardReader.underlyingError.offlineCollectAndConfirmMismatch", + value: "Please ensure the network connection is consistent at payment collection and confirmation.", + comment: "Error message when there is a mismatch between offline collect and confirm." + ) + + case .onlinePinNotSupportedOffline: + return NSLocalizedString( + "hardware.cardReader.underlyingError.onlinePinNotSupportedOffline", + value: "Online PIN is not supported in offline mode. Please retry the payment with another card.", + comment: "Error message when online PIN is not supported in offline mode." + ) + + case .offlineTestCardInLivemode: + return NSLocalizedString( + "hardware.cardReader.underlyingError.offlineTestCardInLivemode", + value: "A test card is used in live mode while offline.", + comment: "Error message when a test card is used in live mode while offline." + ) + + case .stripeAPIResponseDecodingError: + return NSLocalizedString( + "hardware.cardReader.underlyingError.stripeAPIResponseDecodingError", + value: "Please contact support - there was an error decoding the Stripe API response.", + comment: "Error message when there is an error decoding the Stripe API response." + ) + + case .internalNetworkError: + return NSLocalizedString( + "hardware.cardReader.underlyingError.internalNetworkError", + value: "An unknown network error occurred.", + comment: "Error message when an internal network error occurs." + ) + + case .connectionTokenProviderCompletedWithError: + return NSLocalizedString( + "hardware.cardReader.underlyingError.connectionTokenProviderCompletedWithError", + value: "There was an error fetching the connection token.", + comment: "Error message when the connection token provider finishes with an error." + ) + + case .connectionTokenProviderTimedOut: + return NSLocalizedString( + "hardware.cardReader.underlyingError.connectionTokenProviderTimedOut", + value: "The connection token request timed out.", + comment: "Error message when the connection token provider operation times out." + ) } } } diff --git a/Hardware/HardwareTests/ErrorCodesTests.swift b/Hardware/HardwareTests/ErrorCodesTests.swift index d8a6ef269a2..20bb9406a85 100644 --- a/Hardware/HardwareTests/ErrorCodesTests.swift +++ b/Hardware/HardwareTests/ErrorCodesTests.swift @@ -218,6 +218,166 @@ final class CardReaderServiceErrorTests: XCTestCase { XCTAssertEqual(.invalidCurrency, domainError(stripeCode: 2950)) } + func test_stripe_cancel_failed_already_completed_maps_to_expected_error() { + XCTAssertEqual(.cancelFailedAlreadyCompleted, domainError(stripeCode: 1010)) + } + + func test_stripe_nil_payment_intent_maps_to_expected_error() { + XCTAssertEqual(.nilPaymentIntent, domainError(stripeCode: 1540)) + } + + func test_stripe_nil_setup_intent_maps_to_expected_error() { + XCTAssertEqual(.nilSetupIntent, domainError(stripeCode: 1542)) + } + + func test_stripe_nil_refund_payment_method_maps_to_expected_error() { + XCTAssertEqual(.nilRefundPaymentMethod, domainError(stripeCode: 1550)) + } + + func test_stripe_invalid_refund_parameters_maps_to_expected_error() { + XCTAssertEqual(.invalidRefundParameters, domainError(stripeCode: 1555)) + } + + func test_stripe_invalid_client_secret_maps_to_expected_error() { + XCTAssertEqual(.invalidClientSecret, domainError(stripeCode: 1560)) + } + + func test_stripe_invalid_discovery_configuration_maps_to_expected_error() { + XCTAssertEqual(.invalidDiscoveryConfiguration, domainError(stripeCode: 1590)) + } + + func test_stripe_invalid_reader_for_update_maps_to_expected_error() { + XCTAssertEqual(.invalidReaderForUpdate, domainError(stripeCode: 1861)) + } + + func test_stripe_feature_not_available_maps_to_expected_error() { + XCTAssertEqual(.featureNotAvailable, domainError(stripeCode: 1890)) + } + + func test_stripe_bluetooth_connection_invalid_location_id_parameter_maps_to_expected_error() { + XCTAssertEqual(.bluetoothConnectionInvalidLocationIdParameter, domainError(stripeCode: 1910)) + } + + func test_stripe_invalid_required_parameter_maps_to_expected_error() { + XCTAssertEqual(.invalidRequiredParameter, domainError(stripeCode: 1920)) + } + + func test_stripe_forwarding_test_mode_payment_in_live_mode_maps_to_expected_error() { + XCTAssertEqual(.forwardingTestModePaymentInLiveMode, domainError(stripeCode: 1937)) + } + + func test_stripe_forwarding_live_mode_payment_in_test_mode_maps_to_expected_error() { + XCTAssertEqual(.forwardingLiveModePaymentInTestMode, domainError(stripeCode: 1938)) + } + + func test_stripe_reader_connection_configuration_invalid_maps_to_expected_error() { + XCTAssertEqual(.readerConnectionConfigurationInvalid, domainError(stripeCode: 1940)) + } + + func test_stripe_reader_tipping_parameter_invalid_maps_to_expected_error() { + XCTAssertEqual(.readerTippingParameterInvalid, domainError(stripeCode: 1950)) + } + + func test_stripe_invalid_location_id_parameter_maps_to_expected_error() { + XCTAssertEqual(.invalidLocationIdParameter, domainError(stripeCode: 1960)) + } + + func test_stripe_reader_software_update_failed_expired_update_maps_to_expected_error() { + XCTAssertEqual(.readerSoftwareUpdateFailedExpiredUpdate, domainError(stripeCode: 2670)) + } + + func test_stripe_missing_emv_data_maps_to_expected_error() { + XCTAssertEqual(.missingEMVData, domainError(stripeCode: 2892)) + } + + func test_stripe_command_not_allowed_maps_to_expected_error() { + XCTAssertEqual(.commandNotAllowed, domainError(stripeCode: 2900)) + } + + func test_stripe_bluetooth_peer_removed_pairing_information_maps_to_expected_error() { + XCTAssertEqual(.bluetoothPeerRemovedPairingInformation, domainError(stripeCode: 3240)) + } + + func test_stripe_bluetooth_already_paired_with_another_device_maps_to_expected_error() { + XCTAssertEqual(.bluetoothAlreadyPairedWithAnotherDevice, domainError(stripeCode: 3241)) + } + + func test_stripe_unknown_reader_ip_address_maps_to_expected_error() { + XCTAssertEqual(.unknownReaderIpAddress, domainError(stripeCode: 3860)) + } + + func test_stripe_internet_connect_time_out_maps_to_expected_error() { + XCTAssertEqual(.internetConnectTimeOut, domainError(stripeCode: 3870)) + } + + func test_stripe_bluetooth_reconnect_started_maps_to_expected_error() { + XCTAssertEqual(.bluetoothReconnectStarted, domainError(stripeCode: 3890)) + } + + func test_stripe_apple_built_in_reader_account_deactivated_maps_to_expected_error() { + XCTAssertEqual(.appleBuiltInReaderAccountDeactivated, domainError(stripeCode: 3970)) + } + + func test_stripe_reader_missing_encryption_keys_maps_to_expected_error() { + XCTAssertEqual(.readerMissingEncryptionKeys, domainError(stripeCode: 3980)) + } + + func test_stripe_unexpected_reader_error_maps_to_expected_error() { + XCTAssertEqual(.unexpectedReaderError, domainError(stripeCode: 5001)) + } + + func test_stripe_command_requires_cardholder_consent_maps_to_expected_error() { + XCTAssertEqual(.commandRequiresCardholderConsent, domainError(stripeCode: 6700)) + } + + func test_stripe_refund_failed_maps_to_expected_error() { + XCTAssertEqual(.refundFailed, domainError(stripeCode: 6800)) + } + + func test_stripe_card_swipe_not_available_maps_to_expected_error() { + XCTAssertEqual(.cardSwipeNotAvailable, domainError(stripeCode: 6900)) + } + + func test_stripe_interac_not_supported_offline_maps_to_expected_error() { + XCTAssertEqual(.interacNotSupportedOffline, domainError(stripeCode: 6901)) + } + + func test_stripe_offline_and_card_expired_maps_to_expected_error() { + XCTAssertEqual(.offlineAndCardExpired, domainError(stripeCode: 6902)) + } + + func test_stripe_offline_transaction_declined_maps_to_expected_error() { + XCTAssertEqual(.offlineTransactionDeclined, domainError(stripeCode: 6903)) + } + + func test_stripe_offline_collect_and_confirm_mismatch_maps_to_expected_error() { + XCTAssertEqual(.offlineCollectAndConfirmMismatch, domainError(stripeCode: 6904)) + } + + func test_stripe_online_pin_not_supported_offline_maps_to_expected_error() { + XCTAssertEqual(.onlinePinNotSupportedOffline, domainError(stripeCode: 6905)) + } + + func test_stripe_offline_test_card_in_livemode_maps_to_expected_error() { + XCTAssertEqual(.offlineTestCardInLivemode, domainError(stripeCode: 6906)) + } + + func test_stripe_api_response_decoding_error_maps_to_expected_error() { + XCTAssertEqual(.stripeAPIResponseDecodingError, domainError(stripeCode: 9030)) + } + + func test_stripe_internal_network_error_maps_to_expected_error() { + XCTAssertEqual(.internalNetworkError, domainError(stripeCode: 9040)) + } + + func test_stripe_connection_token_provider_completed_with_error_maps_to_expected_error() { + XCTAssertEqual(.connectionTokenProviderCompletedWithError, domainError(stripeCode: 9050)) + } + + func test_stripe_connection_token_provider_timed_out_maps_to_expected_error() { + XCTAssertEqual(.connectionTokenProviderTimedOut, domainError(stripeCode: 9052)) + } + func test_stripe_catch_all_error() { // Any error code not mapped to an specific error will be // mapped to `internalServiceError` diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index bfb4b833db6..29d7b18e730 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -76,6 +76,10 @@ 028F3F942B0DF9A800F8E227 /* OrderEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F3F932B0DF9A800F8E227 /* OrderEncoderTests.swift */; }; 028FA473257E110700F88A48 /* shipping-label-refund-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 028FA471257E110700F88A48 /* shipping-label-refund-error.json */; }; 028FA474257E110700F88A48 /* shipping-label-refund-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 028FA472257E110700F88A48 /* shipping-label-refund-success.json */; }; + 0291496E2D2634F800F7B3B3 /* SystemStatusReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0291496D2D2634F800F7B3B3 /* SystemStatusReport.swift */; }; + 029149702D263AE700F7B3B3 /* SystemStatusReportMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0291496F2D263AE700F7B3B3 /* SystemStatusReportMapper.swift */; }; + 029149722D2641DD00F7B3B3 /* SystemStatusReportMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029149712D2641DD00F7B3B3 /* SystemStatusReportMapperTests.swift */; }; + 029149742D26430E00F7B3B3 /* systemStatus-inconsistent-environment-max-upload-size-data-type.json in Resources */ = {isa = PBXBuildFile; fileRef = 029149732D26430E00F7B3B3 /* systemStatus-inconsistent-environment-max-upload-size-data-type.json */; }; 02935AEE29DFFA74001B793E /* site-enable-trial-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02935AEC29DFFA74001B793E /* site-enable-trial-success.json */; }; 02935AEF29DFFA74001B793E /* site-enable-trial-error-already-upgraded.json in Resources */ = {isa = PBXBuildFile; fileRef = 02935AED29DFFA74001B793E /* site-enable-trial-error-already-upgraded.json */; }; 029B86902A6FBBE000E944D1 /* wcpay-account-null-isLive.json in Resources */ = {isa = PBXBuildFile; fileRef = 029B868F2A6FBBE000E944D1 /* wcpay-account-null-isLive.json */; }; @@ -93,7 +97,7 @@ 02AED9D82AA03F3F006DC460 /* order-with-all-addon-types.json in Resources */ = {isa = PBXBuildFile; fileRef = 02AED9D72AA03F3F006DC460 /* order-with-all-addon-types.json */; }; 02AED9DC2AA04716006DC460 /* KeyedDecodingContainer+WooTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AED9DB2AA04716006DC460 /* KeyedDecodingContainer+WooTests.swift */; }; 02AF07EA27492DBC00B2D81E /* WordPressMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AF07E927492DBC00B2D81E /* WordPressMedia.swift */; }; - 02AF07EC27492FDD00B2D81E /* media-library-from-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = 02AF07EB27492FDD00B2D81E /* media-library-from-wordpress-site.json */; }; + 02AF07EC27492FDD00B2D81E /* media-library.json in Resources */ = {isa = PBXBuildFile; fileRef = 02AF07EB27492FDD00B2D81E /* media-library.json */; }; 02B41A90296BC85800FE3311 /* site-domains.json in Resources */ = {isa = PBXBuildFile; fileRef = 02B41A8F296BC85800FE3311 /* site-domains.json */; }; 02B41A92296BEB3000FE3311 /* load-site-current-plan-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02B41A91296BEB3000FE3311 /* load-site-current-plan-success.json */; }; 02B41A94296C04BC00FE3311 /* load-site-plans-no-current-plan.json in Resources */ = {isa = PBXBuildFile; fileRef = 02B41A93296C04BC00FE3311 /* load-site-plans-no-current-plan.json */; }; @@ -130,7 +134,6 @@ 02EF1672292F0D1900D90AD6 /* load-plan-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02EF1671292F0D1900D90AD6 /* load-plan-success.json */; }; 02EFF81D2ABC3D0E0015ABB2 /* order-gift-card-invalid-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 02EFF81B2ABC3D0E0015ABB2 /* order-gift-card-invalid-error.json */; }; 02EFF81E2ABC3D0E0015ABB2 /* order-gift-card-cannot-apply-error.json in Resources */ = {isa = PBXBuildFile; fileRef = 02EFF81C2ABC3D0E0015ABB2 /* order-gift-card-cannot-apply-error.json */; }; - 02F096C22406691100C0C1D5 /* media-library.json in Resources */ = {isa = PBXBuildFile; fileRef = 02F096C12406691100C0C1D5 /* media-library.json */; }; 02F4AA2929791623002AA0E8 /* create-doman-cart-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 02F4AA2829791623002AA0E8 /* create-doman-cart-success.json */; }; 0313651928AE559D00EEE571 /* PaymentGatewayMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313651828AE559D00EEE571 /* PaymentGatewayMapper.swift */; }; 0313651B28AE60E000EEE571 /* payment-gateway-cod.json in Resources */ = {isa = PBXBuildFile; fileRef = 0313651A28AE60E000EEE571 /* payment-gateway-cod.json */; }; @@ -955,11 +958,14 @@ D8FBFF2422D52815006E3336 /* order-stats-v4-daily.json in Resources */ = {isa = PBXBuildFile; fileRef = D8FBFF2322D52815006E3336 /* order-stats-v4-daily.json */; }; D8FBFF2722D529F2006E3336 /* order-stats-v4-month.json in Resources */ = {isa = PBXBuildFile; fileRef = D8FBFF2622D529F2006E3336 /* order-stats-v4-month.json */; }; D8FBFF2922D52AFB006E3336 /* order-stats-v4-year.json in Resources */ = {isa = PBXBuildFile; fileRef = D8FBFF2822D52AFA006E3336 /* order-stats-v4-year.json */; }; + DA2415292D10684D0008F69A /* wooshipping-get-origin-addresses-success.json in Resources */ = {isa = PBXBuildFile; fileRef = DA2415282D10684D0008F69A /* wooshipping-get-origin-addresses-success.json */; }; DA69CC972CEDDA1200CB7CEE /* wooshipping-get-account-settings-success.json in Resources */ = {isa = PBXBuildFile; fileRef = DA69CC962CEDDA1200CB7CEE /* wooshipping-get-account-settings-success.json */; }; DAA259AB2CEC86370035F028 /* WooShippingSavedPredefinedPackage.swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA259AA2CEC86360035F028 /* WooShippingSavedPredefinedPackage.swift.swift */; }; DAA259AD2CEC86BE0035F028 /* wooshipping-get-packages-success.json in Resources */ = {isa = PBXBuildFile; fileRef = DAA259AC2CEC86BE0035F028 /* wooshipping-get-packages-success.json */; }; DAA259AF2CECF4AF0035F028 /* WooShippingAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA259AE2CECF4A70035F028 /* WooShippingAccountSettings.swift */; }; DAA259B12CECF5720035F028 /* WooShippingAccountSettingsMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA259B02CECF56B0035F028 /* WooShippingAccountSettingsMapper.swift */; }; + DAEE64282D1048A00031DCDC /* WooShippingOriginAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE64272D1048930031DCDC /* WooShippingOriginAddress.swift */; }; + DAEE642A2D104B7C0031DCDC /* WooShippingOriginAddressesMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAEE64292D104B720031DCDC /* WooShippingOriginAddressesMapper.swift */; }; DAF367A22CE75B9E00D1B327 /* WooShippingPackagesMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF367A12CE75B9D00D1B327 /* WooShippingPackagesMapper.swift */; }; DAF367A42CE75C1B00D1B327 /* WooShippingPredefinedPackage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF367A32CE75C1900D1B327 /* WooShippingPredefinedPackage.swift */; }; DAF367A62CE75C2F00D1B327 /* WooShippingPredefinedSavedOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF367A52CE75C2D00D1B327 /* WooShippingPredefinedSavedOption.swift */; }; @@ -1054,6 +1060,8 @@ DE78DE4A2B2AEC7F002E58DE /* wp-page-list-success.json in Resources */ = {isa = PBXBuildFile; fileRef = DE78DE492B2AEC7F002E58DE /* wp-page-list-success.json */; }; DE78DE4C2B2AED4C002E58DE /* WordPressPageMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE78DE4B2B2AED4C002E58DE /* WordPressPageMapperTests.swift */; }; DE78DE4E2B2BF0EA002E58DE /* product-subscription-alternative-types.json in Resources */ = {isa = PBXBuildFile; fileRef = DE78DE4D2B2BF0EA002E58DE /* product-subscription-alternative-types.json */; }; + DE8BEF0B2D141DD9008B3A3F /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8BEF0A2D141DD9008B3A3F /* NotificationSettings.swift */; }; + DE8BEF0D2D151396008B3A3F /* notification-settings.json in Resources */ = {isa = PBXBuildFile; fileRef = DE8BEF0C2D151396008B3A3F /* notification-settings.json */; }; DE970D842C23E3F60019EF42 /* product-report-string-stock-quantity.json in Resources */ = {isa = PBXBuildFile; fileRef = DE970D832C23E3F60019EF42 /* product-report-string-stock-quantity.json */; }; DE97C3922861B8E20042E973 /* CouponEncoderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE97C3912861B8E20042E973 /* CouponEncoderTests.swift */; }; DE9D6BCC270D769C00BA6562 /* shipping-label-address-without-name-validation-success.json in Resources */ = {isa = PBXBuildFile; fileRef = DE9D6BCB270D769B00BA6562 /* shipping-label-address-without-name-validation-success.json */; }; @@ -1089,17 +1097,17 @@ DEC51A97274DD962009F3DF4 /* plugin.json in Resources */ = {isa = PBXBuildFile; fileRef = DEC51A96274DD962009F3DF4 /* plugin.json */; }; DEC51A99274DDDC9009F3DF4 /* SitePluginMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51A98274DDDC9009F3DF4 /* SitePluginMapperTests.swift */; }; DEC51A9B274E3206009F3DF4 /* plugin-inactive.json in Resources */ = {isa = PBXBuildFile; fileRef = DEC51A9A274E3206009F3DF4 /* plugin-inactive.json */; }; - DEC51AE7276848A9009F3DF4 /* SystemStatus+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AE6276848A9009F3DF4 /* SystemStatus+Environment.swift */; }; + DEC51AE7276848A9009F3DF4 /* SystemStatusReport+Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AE6276848A9009F3DF4 /* SystemStatusReport+Environment.swift */; }; DEC51AE927687AAF009F3DF4 /* SystemPluginMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AE827687AAF009F3DF4 /* SystemPluginMapper.swift */; }; DEC51AED2768A0AD009F3DF4 /* systemStatusWithPluginsOnly.json in Resources */ = {isa = PBXBuildFile; fileRef = DEC51AEC2768A0AD009F3DF4 /* systemStatusWithPluginsOnly.json */; }; - DEC51AEF2768A628009F3DF4 /* SystemStatus+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AEE2768A628009F3DF4 /* SystemStatus+Database.swift */; }; - DEC51AF127699E7A009F3DF4 /* SystemStatus+Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF027699E7A009F3DF4 /* SystemStatus+Page.swift */; }; - DEC51AF327699ECE009F3DF4 /* SystemStatus+PostTypeCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF227699ECE009F3DF4 /* SystemStatus+PostTypeCount.swift */; }; - DEC51AF527699F3A009F3DF4 /* SystemStatus+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF427699F3A009F3DF4 /* SystemStatus+Theme.swift */; }; - DEC51AF72769A15B009F3DF4 /* SystemStatus+Security.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF62769A15B009F3DF4 /* SystemStatus+Security.swift */; }; - DEC51AF92769A212009F3DF4 /* SystemStatus+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF82769A212009F3DF4 /* SystemStatus+Settings.swift */; }; + DEC51AEF2768A628009F3DF4 /* SystemStatusReport+Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AEE2768A628009F3DF4 /* SystemStatusReport+Database.swift */; }; + DEC51AF127699E7A009F3DF4 /* SystemStatusReport+Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF027699E7A009F3DF4 /* SystemStatusReport+Page.swift */; }; + DEC51AF327699ECE009F3DF4 /* SystemStatusReport+PostTypeCount.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF227699ECE009F3DF4 /* SystemStatusReport+PostTypeCount.swift */; }; + DEC51AF527699F3A009F3DF4 /* SystemStatusReport+Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF427699F3A009F3DF4 /* SystemStatusReport+Theme.swift */; }; + DEC51AF72769A15B009F3DF4 /* SystemStatusReport+Security.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF62769A15B009F3DF4 /* SystemStatusReport+Security.swift */; }; + DEC51AF92769A212009F3DF4 /* SystemStatusReport+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF82769A212009F3DF4 /* SystemStatusReport+Settings.swift */; }; DEC51AFB2769C66B009F3DF4 /* SystemStatusMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */; }; - DEC51B02276AFB35009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */; }; + DEC51B02276AFB35009F3DF4 /* SystemStatusReport+DropinMustUsePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51B01276AFB34009F3DF4 /* SystemStatusReport+DropinMustUsePlugin.swift */; }; DED91DE52AD64B2900CDCC53 /* BlazeRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED91DE42AD64B2900CDCC53 /* BlazeRemote.swift */; }; DED91DE72AD64ED500CDCC53 /* BlazeRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED91DE62AD64ED500CDCC53 /* BlazeRemoteTests.swift */; }; DEDA8D9B2B05BEF80076BF0F /* CreateProductVariationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDA8D9A2B05BEF80076BF0F /* CreateProductVariationTests.swift */; }; @@ -1362,6 +1370,10 @@ 028F3F932B0DF9A800F8E227 /* OrderEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderEncoderTests.swift; sourceTree = ""; }; 028FA471257E110700F88A48 /* shipping-label-refund-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "shipping-label-refund-error.json"; sourceTree = ""; }; 028FA472257E110700F88A48 /* shipping-label-refund-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "shipping-label-refund-success.json"; sourceTree = ""; }; + 0291496D2D2634F800F7B3B3 /* SystemStatusReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusReport.swift; sourceTree = ""; }; + 0291496F2D263AE700F7B3B3 /* SystemStatusReportMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusReportMapper.swift; sourceTree = ""; }; + 029149712D2641DD00F7B3B3 /* SystemStatusReportMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusReportMapperTests.swift; sourceTree = ""; }; + 029149732D26430E00F7B3B3 /* systemStatus-inconsistent-environment-max-upload-size-data-type.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "systemStatus-inconsistent-environment-max-upload-size-data-type.json"; sourceTree = ""; }; 02935AEC29DFFA74001B793E /* site-enable-trial-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-enable-trial-success.json"; sourceTree = ""; }; 02935AED29DFFA74001B793E /* site-enable-trial-error-already-upgraded.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-enable-trial-error-already-upgraded.json"; sourceTree = ""; }; 029B868F2A6FBBE000E944D1 /* wcpay-account-null-isLive.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "wcpay-account-null-isLive.json"; sourceTree = ""; }; @@ -1379,7 +1391,7 @@ 02AED9D72AA03F3F006DC460 /* order-with-all-addon-types.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "order-with-all-addon-types.json"; sourceTree = ""; }; 02AED9DB2AA04716006DC460 /* KeyedDecodingContainer+WooTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyedDecodingContainer+WooTests.swift"; sourceTree = ""; }; 02AF07E927492DBC00B2D81E /* WordPressMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressMedia.swift; sourceTree = ""; }; - 02AF07EB27492FDD00B2D81E /* media-library-from-wordpress-site.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-library-from-wordpress-site.json"; sourceTree = ""; }; + 02AF07EB27492FDD00B2D81E /* media-library.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-library.json"; sourceTree = ""; }; 02B41A8F296BC85800FE3311 /* site-domains.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-domains.json"; sourceTree = ""; }; 02B41A91296BEB3000FE3311 /* load-site-current-plan-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "load-site-current-plan-success.json"; sourceTree = ""; }; 02B41A93296C04BC00FE3311 /* load-site-plans-no-current-plan.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "load-site-plans-no-current-plan.json"; sourceTree = ""; }; @@ -1416,7 +1428,6 @@ 02EF1671292F0D1900D90AD6 /* load-plan-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "load-plan-success.json"; sourceTree = ""; }; 02EFF81B2ABC3D0E0015ABB2 /* order-gift-card-invalid-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "order-gift-card-invalid-error.json"; sourceTree = ""; }; 02EFF81C2ABC3D0E0015ABB2 /* order-gift-card-cannot-apply-error.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "order-gift-card-cannot-apply-error.json"; sourceTree = ""; }; - 02F096C12406691100C0C1D5 /* media-library.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-library.json"; sourceTree = ""; }; 02F4AA2829791623002AA0E8 /* create-doman-cart-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "create-doman-cart-success.json"; sourceTree = ""; }; 0313651828AE559D00EEE571 /* PaymentGatewayMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentGatewayMapper.swift; sourceTree = ""; }; 0313651A28AE60E000EEE571 /* payment-gateway-cod.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "payment-gateway-cod.json"; sourceTree = ""; }; @@ -2140,11 +2151,14 @@ D8FBFF2322D52815006E3336 /* order-stats-v4-daily.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "order-stats-v4-daily.json"; sourceTree = ""; }; D8FBFF2622D529F2006E3336 /* order-stats-v4-month.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "order-stats-v4-month.json"; sourceTree = ""; }; D8FBFF2822D52AFA006E3336 /* order-stats-v4-year.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "order-stats-v4-year.json"; sourceTree = ""; }; + DA2415282D10684D0008F69A /* wooshipping-get-origin-addresses-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wooshipping-get-origin-addresses-success.json"; sourceTree = ""; }; DA69CC962CEDDA1200CB7CEE /* wooshipping-get-account-settings-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wooshipping-get-account-settings-success.json"; sourceTree = ""; }; DAA259AA2CEC86360035F028 /* WooShippingSavedPredefinedPackage.swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingSavedPredefinedPackage.swift.swift; sourceTree = ""; }; DAA259AC2CEC86BE0035F028 /* wooshipping-get-packages-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wooshipping-get-packages-success.json"; sourceTree = ""; }; DAA259AE2CECF4A70035F028 /* WooShippingAccountSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAccountSettings.swift; sourceTree = ""; }; DAA259B02CECF56B0035F028 /* WooShippingAccountSettingsMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAccountSettingsMapper.swift; sourceTree = ""; }; + DAEE64272D1048930031DCDC /* WooShippingOriginAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingOriginAddress.swift; sourceTree = ""; }; + DAEE64292D104B720031DCDC /* WooShippingOriginAddressesMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingOriginAddressesMapper.swift; sourceTree = ""; }; DAF367A12CE75B9D00D1B327 /* WooShippingPackagesMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingPackagesMapper.swift; sourceTree = ""; }; DAF367A32CE75C1900D1B327 /* WooShippingPredefinedPackage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingPredefinedPackage.swift; sourceTree = ""; }; DAF367A52CE75C2D00D1B327 /* WooShippingPredefinedSavedOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingPredefinedSavedOption.swift; sourceTree = ""; }; @@ -2241,6 +2255,8 @@ DE78DE492B2AEC7F002E58DE /* wp-page-list-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "wp-page-list-success.json"; sourceTree = ""; }; DE78DE4B2B2AED4C002E58DE /* WordPressPageMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressPageMapperTests.swift; sourceTree = ""; }; DE78DE4D2B2BF0EA002E58DE /* product-subscription-alternative-types.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-subscription-alternative-types.json"; sourceTree = ""; }; + DE8BEF0A2D141DD9008B3A3F /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = ""; }; + DE8BEF0C2D151396008B3A3F /* notification-settings.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "notification-settings.json"; sourceTree = ""; }; DE970D832C23E3F60019EF42 /* product-report-string-stock-quantity.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "product-report-string-stock-quantity.json"; sourceTree = ""; }; DE97C3912861B8E20042E973 /* CouponEncoderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponEncoderTests.swift; sourceTree = ""; }; DE9D6BCB270D769B00BA6562 /* shipping-label-address-without-name-validation-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "shipping-label-address-without-name-validation-success.json"; sourceTree = ""; }; @@ -2276,17 +2292,17 @@ DEC51A96274DD962009F3DF4 /* plugin.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = plugin.json; sourceTree = ""; }; DEC51A98274DDDC9009F3DF4 /* SitePluginMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePluginMapperTests.swift; sourceTree = ""; }; DEC51A9A274E3206009F3DF4 /* plugin-inactive.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "plugin-inactive.json"; sourceTree = ""; }; - DEC51AE6276848A9009F3DF4 /* SystemStatus+Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Environment.swift"; sourceTree = ""; }; + DEC51AE6276848A9009F3DF4 /* SystemStatusReport+Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Environment.swift"; sourceTree = ""; }; DEC51AE827687AAF009F3DF4 /* SystemPluginMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemPluginMapper.swift; sourceTree = ""; }; DEC51AEC2768A0AD009F3DF4 /* systemStatusWithPluginsOnly.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = systemStatusWithPluginsOnly.json; sourceTree = ""; }; - DEC51AEE2768A628009F3DF4 /* SystemStatus+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Database.swift"; sourceTree = ""; }; - DEC51AF027699E7A009F3DF4 /* SystemStatus+Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Page.swift"; sourceTree = ""; }; - DEC51AF227699ECE009F3DF4 /* SystemStatus+PostTypeCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+PostTypeCount.swift"; sourceTree = ""; }; - DEC51AF427699F3A009F3DF4 /* SystemStatus+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Theme.swift"; sourceTree = ""; }; - DEC51AF62769A15B009F3DF4 /* SystemStatus+Security.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Security.swift"; sourceTree = ""; }; - DEC51AF82769A212009F3DF4 /* SystemStatus+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Settings.swift"; sourceTree = ""; }; + DEC51AEE2768A628009F3DF4 /* SystemStatusReport+Database.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Database.swift"; sourceTree = ""; }; + DEC51AF027699E7A009F3DF4 /* SystemStatusReport+Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Page.swift"; sourceTree = ""; }; + DEC51AF227699ECE009F3DF4 /* SystemStatusReport+PostTypeCount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+PostTypeCount.swift"; sourceTree = ""; }; + DEC51AF427699F3A009F3DF4 /* SystemStatusReport+Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Theme.swift"; sourceTree = ""; }; + DEC51AF62769A15B009F3DF4 /* SystemStatusReport+Security.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Security.swift"; sourceTree = ""; }; + DEC51AF82769A212009F3DF4 /* SystemStatusReport+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+Settings.swift"; sourceTree = ""; }; DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusMapperTests.swift; sourceTree = ""; }; - DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+DropinMustUsePlugin.swift"; sourceTree = ""; }; + DEC51B01276AFB34009F3DF4 /* SystemStatusReport+DropinMustUsePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatusReport+DropinMustUsePlugin.swift"; sourceTree = ""; }; DED91DE42AD64B2900CDCC53 /* BlazeRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeRemote.swift; sourceTree = ""; }; DED91DE62AD64ED500CDCC53 /* BlazeRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeRemoteTests.swift; sourceTree = ""; }; DEDA8D9A2B05BEF80076BF0F /* CreateProductVariationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateProductVariationTests.swift; sourceTree = ""; }; @@ -2571,6 +2587,7 @@ CEC7D5902CDD0C1C00111B79 /* WooShippingCreatePackageResponse.swift */, DAF367A72CE75C5B00D1B327 /* WooShippingPackagesResponse.swift */, CE1EA4BE2CEBA0AF0039F477 /* WooShippingPackagePurchase.swift */, + DAEE64272D1048930031DCDC /* WooShippingOriginAddress.swift */, 451A97C82609FF050059D135 /* ShippingLabelPackagesResponse.swift */, 451A97CC260A01A40059D135 /* ShippingLabelStoreOptions.swift */, CCAAD10E2683974000909664 /* ShippingLabelPackagePurchase.swift */, @@ -3007,6 +3024,7 @@ B59325CF217E4206000B0E8E /* NoteMedia.swift */, B59325D1217E4206000B0E8E /* NoteRange.swift */, B554FA902180BCFC00C54DFF /* NoteHash.swift */, + DE8BEF0A2D141DD9008B3A3F /* NotificationSettings.swift */, B557DA1C20979E7D005962F4 /* Order.swift */, 741B950020EBC8A700DD6E2D /* OrderCouponLine.swift */, D88E228F25AC990A0023F3B1 /* OrderFeeLine.swift */, @@ -3048,6 +3066,7 @@ CC75108A29EFF1A90035FBA4 /* SubscriptionStatus.swift */, 077F39C7269F2C7E00ABEADC /* SystemPlugin.swift */, 3148976F27232982007A86BD /* SystemStatus.swift */, + 0291496D2D2634F800F7B3B3 /* SystemStatusReport.swift */, DEC51AE527684717009F3DF4 /* SystemStatusDetails */, 450106842399A7CB00E24722 /* TaxClass.swift */, 31799AF7270508C600D78179 /* RemoteReaderLocation.swift */, @@ -3084,8 +3103,10 @@ B559EBA820A0B5B100836CD4 /* Responses */ = { isa = PBXGroup; children = ( + DE8BEF0C2D151396008B3A3F /* notification-settings.json */, EED25B1C2CF74B9800503657 /* media-upload.json */, EE6C6B6F2C6A190500632BDA /* systemStatus-inconsistent-page-id-data-type.json */, + 029149732D26430E00F7B3B3 /* systemStatus-inconsistent-environment-max-upload-size-data-type.json */, DEB3878E2C2D71A10025256E /* gla-campaign-list-with-data-envelope.json */, DEB3878D2C2D71A10025256E /* gla-campaign-list-without-data-envelope.json */, DEB387772C2A9ADC0025256E /* gla-connection-with-data-envelope.json */, @@ -3264,8 +3285,7 @@ B505F6D420BEE4E600BB1B69 /* me.json */, 93D8BBFE226BC1DA00AD2EB3 /* me-settings.json */, EEDADD272B7C6A5E00F7302B /* media.json */, - 02F096C12406691100C0C1D5 /* media-library.json */, - 02AF07EB27492FDD00B2D81E /* media-library-from-wordpress-site.json */, + 02AF07EB27492FDD00B2D81E /* media-library.json */, EECB7EE7286555180028C888 /* media-update-product-id.json */, B58D10C92114D22E00107ED4 /* new-order-note.json */, 022902D322E2436400059692 /* no_stats_permission_error.json */, @@ -3536,6 +3556,7 @@ CE0F4ECC2CE375DD006339BD /* wooshipping-get-label-rates-error.json */, DA69CC962CEDDA1200CB7CEE /* wooshipping-get-account-settings-success.json */, DAA259AC2CEC86BE0035F028 /* wooshipping-get-packages-success.json */, + DA2415282D10684D0008F69A /* wooshipping-get-origin-addresses-success.json */, CE1EA4BA2CEB78F60039F477 /* wooshipping-purchase-success.json */, CE90E99B2CEFCAA50068D852 /* wooshipping-label-status-success.json */, CE2FB3972CF74C5C0046201C /* wooshipping-label-print-success.json */, @@ -3649,10 +3670,12 @@ CEC7D5942CDD164D00111B79 /* WooShippingCreatePackageMapper.swift */, CE0F4ECE2CE37C4F006339BD /* WooShippingLabelRatesMapper.swift */, DAF367A12CE75B9D00D1B327 /* WooShippingPackagesMapper.swift */, + DAEE64292D104B720031DCDC /* WooShippingOriginAddressesMapper.swift */, CE90E99D2CEFCB100068D852 /* WooShippingStatusMapper.swift */, CE606D8E2BE39426001CB424 /* ShippingMethodMapper.swift */, FE28F6E326842848004465C7 /* UserMapper.swift */, 077F39D326A58DE700ABEADC /* SystemStatusMapper.swift */, + 0291496F2D263AE700F7B3B3 /* SystemStatusReportMapper.swift */, DEC51AE827687AAF009F3DF4 /* SystemPluginMapper.swift */, 02C11275274285FF00F4F0B4 /* WooCommerceAvailabilityMapper.swift */, 02BE0A7A274B695F001176D2 /* WordPressMediaMapper.swift */, @@ -3757,6 +3780,7 @@ 9387A6EF226E3F15001B53D7 /* AccountSettingsMapperTests.swift */, 077F39DB26A58F4800ABEADC /* SystemPluginMapperTests.swift */, DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */, + 029149712D2641DD00F7B3B3 /* SystemStatusReportMapperTests.swift */, 2685C0D9263B551300D9EE97 /* AddOnGroupMapperTests.swift */, 74AB0AC921948CE4008220CD /* CommentResultMapperTests.swift */, 03DCB7432624AD9A00C8953D /* CouponListMapperTests.swift */, @@ -3963,14 +3987,14 @@ DEC51AE527684717009F3DF4 /* SystemStatusDetails */ = { isa = PBXGroup; children = ( - DEC51AEE2768A628009F3DF4 /* SystemStatus+Database.swift */, - DEC51AF82769A212009F3DF4 /* SystemStatus+Settings.swift */, - DEC51AF027699E7A009F3DF4 /* SystemStatus+Page.swift */, - DEC51AF227699ECE009F3DF4 /* SystemStatus+PostTypeCount.swift */, - DEC51AE6276848A9009F3DF4 /* SystemStatus+Environment.swift */, - DEC51AF427699F3A009F3DF4 /* SystemStatus+Theme.swift */, - DEC51AF62769A15B009F3DF4 /* SystemStatus+Security.swift */, - DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */, + DEC51AEE2768A628009F3DF4 /* SystemStatusReport+Database.swift */, + DEC51AF82769A212009F3DF4 /* SystemStatusReport+Settings.swift */, + DEC51AF027699E7A009F3DF4 /* SystemStatusReport+Page.swift */, + DEC51AF227699ECE009F3DF4 /* SystemStatusReport+PostTypeCount.swift */, + DEC51AE6276848A9009F3DF4 /* SystemStatusReport+Environment.swift */, + DEC51AF427699F3A009F3DF4 /* SystemStatusReport+Theme.swift */, + DEC51AF62769A15B009F3DF4 /* SystemStatusReport+Security.swift */, + DEC51B01276AFB34009F3DF4 /* SystemStatusReport+DropinMustUsePlugin.swift */, ); path = SystemStatusDetails; sourceTree = ""; @@ -4305,7 +4329,7 @@ 74A7B4BE217A841400E85A8B /* broken-settings-general.json in Resources */, CEA455B72BB2D64400D932CF /* product-bundle-top-bundles.json in Resources */, 026CF624237D839B009563D4 /* product-variations-load-all.json in Resources */, - 02AF07EC27492FDD00B2D81E /* media-library-from-wordpress-site.json in Resources */, + 02AF07EC27492FDD00B2D81E /* media-library.json in Resources */, CC9A253C26442C71005DE56E /* shipping-label-eligibility-success.json in Resources */, 26B15E442A269F79000C35E4 /* ip-location.json in Resources */, B5A24179217F98F600595DEF /* notifications-load-all.json in Resources */, @@ -4413,6 +4437,7 @@ 743E84F422172D0A00FAC9D7 /* shipment_tracking_multiple.json in Resources */, DEA493722B3997ED00EED015 /* blaze-target-languages.json in Resources */, 02698CF624C17FC1005337C4 /* product-alternative-types.json in Resources */, + DE8BEF0D2D151396008B3A3F /* notification-settings.json in Resources */, 03EB99962907F03000F06A39 /* empty-data-array.json in Resources */, CE070A342BBC52B200017578 /* gift-card-stats-without-data.json in Resources */, 57BE08D82409B63800F6DCED /* reviews-missing-avatar-urls.json in Resources */, @@ -4517,6 +4542,7 @@ EE9826902B17189B00A3F57E /* product-subscription-sync-renewals-day-month-format.json in Resources */, DEB387902C2D71A10025256E /* gla-campaign-list-with-data-envelope.json in Resources */, 7492FAE3217FBDBC00ED2C69 /* settings-general-alt.json in Resources */, + DA2415292D10684D0008F69A /* wooshipping-get-origin-addresses-success.json in Resources */, 93D8BBFF226BC1DA00AD2EB3 /* me-settings.json in Resources */, 74C947842193A6C70024CB60 /* comment-moderate-approved.json in Resources */, DEC51AED2768A0AD009F3DF4 /* systemStatusWithPluginsOnly.json in Resources */, @@ -4552,6 +4578,7 @@ EE1CB9162B4BCFA800AD24D5 /* blaze-ai-suggestions.json in Resources */, DE66C5672977CEB800DAA978 /* shipping-label-status-success-without-data.json in Resources */, D8FBFF2922D52AFB006E3336 /* order-stats-v4-year.json in Resources */, + 029149742D26430E00F7B3B3 /* systemStatus-inconsistent-environment-max-upload-size-data-type.json in Resources */, D865CE65278CA202002C8520 /* stripe-payment-intent-canceled.json in Resources */, DE20046A2BF74B1E00660A72 /* product-stock-string-quantity.json in Resources */, DE42F9672967F61D00D514C2 /* refunds-all-without-data.json in Resources */, @@ -4680,7 +4707,6 @@ CE71E2292A4C35C900DB5376 /* reports-products.json in Resources */, CE19CB11222486A600E8AF7A /* order-statuses.json in Resources */, EEA6584C2966CC4800112DF0 /* product-id-only-without-data.json in Resources */, - 02F096C22406691100C0C1D5 /* media-library.json in Resources */, 028CB717290223CB00331C09 /* create-account-error-password.json in Resources */, CE71E2372A4C3F3900DB5376 /* reports-products-without-data.json in Resources */, D800DA0E25EFEC21001E13CE /* wcpay-connection-token.json in Resources */, @@ -4942,14 +4968,14 @@ 451A97D1260A03900059D135 /* ShippingLabelCustomPackage.swift in Sources */, D88D5A45230BC6F9007B6E01 /* ProductReviewsRemote.swift in Sources */, B59325D4217E4206000B0E8E /* NoteBlock.swift in Sources */, - DEC51AF92769A212009F3DF4 /* SystemStatus+Settings.swift in Sources */, + DEC51AF92769A212009F3DF4 /* SystemStatusReport+Settings.swift in Sources */, D8C251D0230BD72700F49782 /* ProductReviewMapper.swift in Sources */, B557DA1A20979D66005962F4 /* Settings.swift in Sources */, CE132BBA223851F80029DB6C /* ProductCategory.swift in Sources */, 457A574025D1817E000797AD /* ShippingLabelAddressVerification.swift in Sources */, DED91DE52AD64B2900CDCC53 /* BlazeRemote.swift in Sources */, 74ABA1D1213F22CA00FFAD30 /* TopEarnersStatsRemote.swift in Sources */, - DEC51AF127699E7A009F3DF4 /* SystemStatus+Page.swift in Sources */, + DEC51AF127699E7A009F3DF4 /* SystemStatusReport+Page.swift in Sources */, EE99814E295AA7430074AE68 /* RequestAuthenticator.swift in Sources */, 025CA2C0238EB8CB00B05C81 /* ProductShippingClass.swift in Sources */, 02C1CEF424C6A02B00703EBA /* ProductVariationMapper.swift in Sources */, @@ -4992,6 +5018,7 @@ CE21FB182C2AC97200303832 /* GoogleAdsCampaignStatsTotals.swift in Sources */, B5BB1D0C20A2050300112D92 /* DateFormatter+Woo.swift in Sources */, 743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */, + 0291496E2D2634F800F7B3B3 /* SystemStatusReport.swift in Sources */, DEFBA7542949CE6600C35BA9 /* RequestProcessor.swift in Sources */, 451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */, BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */, @@ -5003,7 +5030,7 @@ CEB9BF412BB198860007978A /* ProductBundleStatsRemote.swift in Sources */, 2685C0FA263B5D5300D9EE97 /* AddOnGroupMapper.swift in Sources */, CCE5F38D29EFFBC400087332 /* SubscriptionListMapper.swift in Sources */, - DEC51AF527699F3A009F3DF4 /* SystemStatus+Theme.swift in Sources */, + DEC51AF527699F3A009F3DF4 /* SystemStatusReport+Theme.swift in Sources */, CE430672234B9EB20073CBFF /* OrderItemTax.swift in Sources */, 457453E725A72C9700276508 /* CreateProductVariation.swift in Sources */, 45E461BC26837CC500011BF2 /* DataRemote.swift in Sources */, @@ -5051,6 +5078,7 @@ 0359EA0F27AAC6410048DE2D /* WCPayPaymentMethodDetails.swift in Sources */, 7426CA1121AF30BD004E9FFC /* SiteAPIMapper.swift in Sources */, 57E8FED3246616AC0057CD68 /* Result+Extensions.swift in Sources */, + 029149702D263AE700F7B3B3 /* SystemStatusReportMapper.swift in Sources */, 020D07BC23D856BF00FD9580 /* MediaRemote.swift in Sources */, CE71E2312A4C3DDA00DB5376 /* ProductsReportMapper.swift in Sources */, 0286981429ED2D6400853B88 /* GenerativeContentRemote.swift in Sources */, @@ -5059,7 +5087,7 @@ 743E84EC22171F4600FAC9D7 /* ShipmentTracking.swift in Sources */, DEB3878A2C2D6E470025256E /* GoogleAdsCampaign.swift in Sources */, 68F48B0B28E3B1CD0045C15B /* WCAnalyticsCustomerRemote.swift in Sources */, - DEC51AF327699ECE009F3DF4 /* SystemStatus+PostTypeCount.swift in Sources */, + DEC51AF327699ECE009F3DF4 /* SystemStatusReport+PostTypeCount.swift in Sources */, B56C1EB820EA76F500D749F9 /* Site.swift in Sources */, 26615473242D596B00A31661 /* ProductCategoriesRemote.swift in Sources */, 02C112782742862600F4F0B4 /* WordPressSiteSettingsMapper.swift in Sources */, @@ -5069,6 +5097,7 @@ 45D685F823D0BC78005F87D0 /* ProductSkuMapper.swift in Sources */, 7412A8EA21B6E192005D182A /* ReportRemote.swift in Sources */, CC01CE5A29B0FD61004FF537 /* ProductBundleItem.swift in Sources */, + DAEE642A2D104B7C0031DCDC /* WooShippingOriginAddressesMapper.swift in Sources */, B5A2417D217F9ECC00595DEF /* MetaContainer.swift in Sources */, 025CA2C4238EBC4300B05C81 /* ProductShippingClassRemote.swift in Sources */, D88D5A4B230BCF0A007B6E01 /* ProductReviewListMapper.swift in Sources */, @@ -5139,7 +5168,7 @@ B53EF53C21814900003E146F /* SuccessResultMapper.swift in Sources */, D8EDFE2625EE8A60003D2213 /* ReaderConnectionTokenMapper.swift in Sources */, CC6A1FF5270E042200F6AF4A /* MetaData.swift in Sources */, - DEC51B02276AFB35009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift in Sources */, + DEC51B02276AFB35009F3DF4 /* SystemStatusReport+DropinMustUsePlugin.swift in Sources */, 02C254A4256371B200A04423 /* ShippingLabelSettings.swift in Sources */, 0313651928AE559D00EEE571 /* PaymentGatewayMapper.swift in Sources */, 209AD3C52AC19E7500825D76 /* WooPaymentsDepositsOverviewMapper.swift in Sources */, @@ -5181,7 +5210,7 @@ DE20046C2BFB4D4600660A72 /* ProductReport.swift in Sources */, 4599FC5E24A62AA70056157A /* ProductTagsRemote.swift in Sources */, DAA259B12CECF5720035F028 /* WooShippingAccountSettingsMapper.swift in Sources */, - DEC51AE7276848A9009F3DF4 /* SystemStatus+Environment.swift in Sources */, + DEC51AE7276848A9009F3DF4 /* SystemStatusReport+Environment.swift in Sources */, DAF367A82CE75C5C00D1B327 /* WooShippingPackagesResponse.swift in Sources */, DE02ABB12B5636FC008E0AC4 /* BlazePaymentInfo.swift in Sources */, FE28F6E226840DED004465C7 /* User.swift in Sources */, @@ -5208,6 +5237,7 @@ 74D522B62113607F00042831 /* StatGranularity.swift in Sources */, 02C2549A25636E1500A04423 /* ShippingLabelAddress.swift in Sources */, 03DCB786262739D200C8953D /* CouponMapper.swift in Sources */, + DAEE64282D1048A00031DCDC /* WooShippingOriginAddress.swift in Sources */, B518662220A097C200037A38 /* Network.swift in Sources */, B572F69A21AC475C003EEFF0 /* DevicesRemote.swift in Sources */, 3192F220260D33BB0067FEF9 /* WCPayAccount.swift in Sources */, @@ -5223,7 +5253,7 @@ 45CDAFAB2434CA9300F83C22 /* ProductCatalogVisibility.swift in Sources */, 3148977027232982007A86BD /* SystemStatus.swift in Sources */, B557DA1820979D51005962F4 /* Credentials.swift in Sources */, - DEC51AF72769A15B009F3DF4 /* SystemStatus+Security.swift in Sources */, + DEC51AF72769A15B009F3DF4 /* SystemStatusReport+Security.swift in Sources */, EE1D9A9F2ACD6BA60020D817 /* AIProduct.swift in Sources */, CE583A0E2109154500D73C1C /* OrderNoteMapper.swift in Sources */, D8FBFF0D22D3AF4A006E3336 /* StatsGranularityV4.swift in Sources */, @@ -5328,6 +5358,7 @@ B5C6FCD420A373BB00A4F8E4 /* OrderMapper.swift in Sources */, CE606D912BE396D7001CB424 /* ShippingMethodsRemote.swift in Sources */, EE1CB90B2B4BC8C500AD24D5 /* BlazeImpressionsMapper.swift in Sources */, + DE8BEF0B2D141DD9008B3A3F /* NotificationSettings.swift in Sources */, D88D5A49230BC8C7007B6E01 /* ProductReviewStatus.swift in Sources */, 036563DB2906938600D84BFD /* JustInTimeMessage.swift in Sources */, CCA1D60429437B2C00B40560 /* SiteSummaryStats.swift in Sources */, @@ -5387,7 +5418,7 @@ 028296F7237D588700E84012 /* ProductVariation.swift in Sources */, DE4D23BA29B5FB3E003A4B5D /* AnnouncementsRemote.swift in Sources */, 453954CA2C909FDE00A3E64A /* MetaDataRemote.swift in Sources */, - DEC51AEF2768A628009F3DF4 /* SystemStatus+Database.swift in Sources */, + DEC51AEF2768A628009F3DF4 /* SystemStatusReport+Database.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5490,6 +5521,7 @@ DE2E8EAB2954170D002E4B14 /* WordPressSiteMapperTests.swift in Sources */, D88D5A4F230BD276007B6E01 /* ProductReviewListMapperTests.swift in Sources */, B567AF3120A0FB8F00AB6C62 /* JetpackRequestTests.swift in Sources */, + 029149722D2641DD00F7B3B3 /* SystemStatusReportMapperTests.swift in Sources */, 7412A8F221B6E47A005D182A /* ReportRemoteTests.swift in Sources */, DE78DE4C2B2AED4C002E58DE /* WordPressPageMapperTests.swift in Sources */, 4513382627A96DB700AE5E78 /* InboxNoteMapperTests.swift in Sources */, diff --git a/Networking/Networking/Mapper/SystemStatusReportMapper.swift b/Networking/Networking/Mapper/SystemStatusReportMapper.swift new file mode 100644 index 00000000000..01491c86dcf --- /dev/null +++ b/Networking/Networking/Mapper/SystemStatusReportMapper.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Mapper: System Status Report +/// +struct SystemStatusReportMapper: Mapper { + + /// Site Identifier associated to the system status that will be parsed. + /// We're injecting this field via `JSONDecoder.userInfo` because the remote endpoints don't return the SiteID in the system plugin endpoint. + /// + let siteID: Int64 + + /// (Attempts) to convert a dictionary into SystemStatusReport + /// + func map(response: Data) throws -> SystemStatusReport { + let decoder = JSONDecoder() + decoder.userInfo = [ + .siteID: siteID + ] + + if hasDataEnvelope(in: response) { + return try decoder.decode(SystemStatusReportEnvelope.self, from: response).systemStatusReport + } else { + return try decoder.decode(SystemStatusReport.self, from: response) + } + } +} + +/// System Status Report endpoint returns the requested account in the `data` key. This entity +/// allows us to parse it with JSONDecoder. +/// +struct SystemStatusReportEnvelope: Decodable { + let systemStatusReport: SystemStatusReport + + private enum CodingKeys: String, CodingKey { + case systemStatusReport = "data" + } +} diff --git a/Networking/Networking/Mapper/WooShippingOriginAddressesMapper.swift b/Networking/Networking/Mapper/WooShippingOriginAddressesMapper.swift new file mode 100644 index 00000000000..291d359e368 --- /dev/null +++ b/Networking/Networking/Mapper/WooShippingOriginAddressesMapper.swift @@ -0,0 +1,26 @@ +import Foundation + +struct WooShippingOriginAddressesMapper: Mapper { + /// (Attempts) to convert a dictionary into WooShippingOriginAddress array. + /// + func map(response: Data) throws -> [WooShippingOriginAddress] { + let decoder = JSONDecoder() + if hasDataEnvelope(in: response) { + return try decoder.decode(WooShippingOriginAddressesMapperEnvelope.self, from: response).data + } else { + return try decoder.decode([WooShippingOriginAddress].self, from: response) + } + } +} + +/// WooShippingOriginAddressesMapperEnvelope Disposable Entity: +/// `Woo Shipping Origin Addresses` endpoint returns the shipping label origin addresses in the `data` key. +/// This entity allows us to do parse all the things with JSONDecoder. +/// +private struct WooShippingOriginAddressesMapperEnvelope: Decodable { + let data: [WooShippingOriginAddress] + + private enum CodingKeys: String, CodingKey { + case data = "data" + } +} diff --git a/Networking/Networking/Mapper/WooShippingPackagesMapper.swift b/Networking/Networking/Mapper/WooShippingPackagesMapper.swift index fa721f71ec2..2e3db9f130d 100644 --- a/Networking/Networking/Mapper/WooShippingPackagesMapper.swift +++ b/Networking/Networking/Mapper/WooShippingPackagesMapper.swift @@ -1,10 +1,19 @@ import Foundation struct WooShippingPackagesMapper: Mapper { + /// Site Identifier associated to the order that will be parsed. + /// + /// We're injecting this field via `JSONDecoder.userInfo` because SiteID is not returned in any of the Order Endpoints. + /// + let siteID: Int64 + /// (Attempts) to convert a dictionary into WooShippingPackagesResponse. /// func map(response: Data) throws -> WooShippingPackagesResponse { let decoder = JSONDecoder() + decoder.userInfo = [ + .siteID: siteID + ] if hasDataEnvelope(in: response) { return try decoder.decode(WooShippingPackagesMapperEnvelope.self, from: response).data } else { diff --git a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift index 06bf4a8081d..addec7afc76 100644 --- a/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Networking/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -4096,6 +4096,57 @@ extension Networking.WooShippingCustomPackage { } } +extension Networking.WooShippingOriginAddress { + public func copy( + id: CopiableProp = .copy, + company: CopiableProp = .copy, + address1: CopiableProp = .copy, + address2: CopiableProp = .copy, + city: CopiableProp = .copy, + state: CopiableProp = .copy, + postcode: CopiableProp = .copy, + country: CopiableProp = .copy, + phone: CopiableProp = .copy, + firstName: CopiableProp = .copy, + lastName: CopiableProp = .copy, + email: CopiableProp = .copy, + defaultAddress: CopiableProp = .copy, + isVerified: CopiableProp = .copy + ) -> Networking.WooShippingOriginAddress { + let id = id ?? self.id + let company = company ?? self.company + let address1 = address1 ?? self.address1 + let address2 = address2 ?? self.address2 + let city = city ?? self.city + let state = state ?? self.state + let postcode = postcode ?? self.postcode + let country = country ?? self.country + let phone = phone ?? self.phone + let firstName = firstName ?? self.firstName + let lastName = lastName ?? self.lastName + let email = email ?? self.email + let defaultAddress = defaultAddress ?? self.defaultAddress + let isVerified = isVerified ?? self.isVerified + + return Networking.WooShippingOriginAddress( + id: id, + company: company, + address1: address1, + address2: address2, + city: city, + state: state, + postcode: postcode, + country: country, + phone: phone, + firstName: firstName, + lastName: lastName, + email: email, + defaultAddress: defaultAddress, + isVerified: isVerified + ) + } +} + extension Networking.WooShippingPackagePurchase { public func copy( shipmentID: CopiableProp = .copy, @@ -4119,18 +4170,18 @@ extension Networking.WooShippingPackagePurchase { extension Networking.WooShippingPackagesResponse { public func copy( - storeOptions: CopiableProp = .copy, + siteID: CopiableProp = .copy, customPackages: CopiableProp<[WooShippingCustomPackage]> = .copy, savedPredefinedPackages: CopiableProp<[WooShippingSavedPredefinedPackage]> = .copy, allPredefinedOptions: CopiableProp<[WooShippingCarrierPredefinedOptions]> = .copy ) -> Networking.WooShippingPackagesResponse { - let storeOptions = storeOptions ?? self.storeOptions + let siteID = siteID ?? self.siteID let customPackages = customPackages ?? self.customPackages let savedPredefinedPackages = savedPredefinedPackages ?? self.savedPredefinedPackages let allPredefinedOptions = allPredefinedOptions ?? self.allPredefinedOptions return Networking.WooShippingPackagesResponse( - storeOptions: storeOptions, + siteID: siteID, customPackages: customPackages, savedPredefinedPackages: savedPredefinedPackages, allPredefinedOptions: allPredefinedOptions diff --git a/Networking/Networking/Model/NotificationSettings.swift b/Networking/Networking/Model/NotificationSettings.swift new file mode 100644 index 00000000000..63c864fde57 --- /dev/null +++ b/Networking/Networking/Model/NotificationSettings.swift @@ -0,0 +1,69 @@ +import Foundation + +/// Notification settings for a user +/// +public struct NotificationSettings: Equatable, Encodable { + + /// Settings for different blogs connected to the user. + public let blogs: [Blog] + + /// Convenience init to create notification settings for a given device ID. + /// + public init(deviceID: Int64, enabledSites: [Int64], disabledSites: [Int64]) { + let enabledSiteSettings = enabledSites.map { siteID in + Blog(blogID: siteID, devices: [ + Device(deviceID: deviceID, + newComment: true, + storeOrder: true) + ]) + } + + let disabledSiteSettings = disabledSites.map { siteID in + Blog(blogID: siteID, devices: [ + Device(deviceID: deviceID, + newComment: false, + storeOrder: false) + ]) + } + + self.init(blogs: (enabledSiteSettings + disabledSiteSettings)) + } + + public init(blogs: [Blog]) { + self.blogs = blogs + } +} + +public extension NotificationSettings { + /// Notification settings for a blog + struct Blog: Equatable, Encodable { + /// ID of the blog + public let blogID: Int64 + + /// List of settings for registered devices + public let devices: [Device] + + enum CodingKeys: String, CodingKey { + case blogID = "blog_id" + case devices + } + } + + /// Notification settings for a device + struct Device: Equatable, Encodable { + /// Unique ID of the device + public let deviceID: Int64 + + /// Whether a notification should be sent when there is a new comment on the blog + public let newComment: Bool + + /// Whether a notification should be sent when there is a new order on the store. + public let storeOrder: Bool + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case newComment = "new_comment" + case storeOrder = "store_order" + } + } +} diff --git a/Networking/Networking/Model/Product/ProductAttribute.swift b/Networking/Networking/Model/Product/ProductAttribute.swift index e5fd3035994..f6c57b5b6e9 100644 --- a/Networking/Networking/Model/Product/ProductAttribute.swift +++ b/Networking/Networking/Model/Product/ProductAttribute.swift @@ -120,6 +120,14 @@ extension ProductAttribute: Comparable { } } +extension ProductAttribute: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(siteID) + hasher.combine(name) + hasher.combine(attributeID) + } +} + // MARK: - Decoding Errors // enum ProductAttributeDecodingError: Error { diff --git a/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelRefundStatus.swift b/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelRefundStatus.swift index 7a537c297eb..ea94b1f0d1f 100644 --- a/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelRefundStatus.swift +++ b/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelRefundStatus.swift @@ -4,6 +4,7 @@ import Codegen /// The status of shipping label refund. public enum ShippingLabelRefundStatus: GeneratedFakeable { case pending + case unknown } /// RawRepresentable Conformance @@ -15,8 +16,8 @@ extension ShippingLabelRefundStatus: RawRepresentable { case Keys.pending: self = .pending default: - assertionFailure("Unexpected value for `ShippingLabelRefundStatus`: \(rawValue)") - self = .pending + DDLogError("⛔️ Unexpected value for `ShippingLabelRefundStatus`: \(rawValue)") + self = .unknown } } @@ -26,6 +27,8 @@ extension ShippingLabelRefundStatus: RawRepresentable { switch self { case .pending: return Keys.pending + case .unknown: + return "" } } } diff --git a/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelStatus.swift b/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelStatus.swift index 71a0d258e7c..549d9c727e5 100644 --- a/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelStatus.swift +++ b/Networking/Networking/Model/ShippingLabel/Enums/ShippingLabelStatus.swift @@ -6,6 +6,7 @@ public enum ShippingLabelStatus: GeneratedFakeable { case purchased case purchaseError case purchaseInProgress + case unknown } /// RawRepresentable Conformance @@ -21,8 +22,8 @@ extension ShippingLabelStatus: RawRepresentable { case Keys.purchaseError: self = .purchaseError default: - assertionFailure("Unexpected value for `ShippingLabelStatus`: \(rawValue)") - self = .purchased + DDLogError("⛔️ Unexpected value for `ShippingLabelStatus`: \(rawValue)") + self = .unknown } } @@ -36,6 +37,8 @@ extension ShippingLabelStatus: RawRepresentable { return Keys.purchaseInProgress case .purchaseError: return Keys.purchaseError + case .unknown: + return "" } } } diff --git a/Networking/Networking/Model/ShippingLabel/Packages/Custom package/WooShippingCustomPackage.swift b/Networking/Networking/Model/ShippingLabel/Packages/Custom package/WooShippingCustomPackage.swift index 940de6accd2..665522b635e 100644 --- a/Networking/Networking/Model/ShippingLabel/Packages/Custom package/WooShippingCustomPackage.swift +++ b/Networking/Networking/Model/ShippingLabel/Packages/Custom package/WooShippingCustomPackage.swift @@ -67,11 +67,16 @@ extension WooShippingCustomPackage: Codable { var boxWeight: Double = 0.0 // Looks like some endpoints have boxWeight as String and some as Double - if let boxWeightDouble = try? container.decodeIfPresent(Double.self, forKey: .boxWeight) { + // and some endpoints have it as box_weight and some as boxWeight + let weightTransformer = AlternativeDecodingType.string { Double($0) ?? 0.0 } + if let boxWeightDouble = container.failsafeDecodeIfPresent(targetType: Double.self, + forKey: .boxWeight, + alternativeTypes: [weightTransformer]) { boxWeight = boxWeightDouble } - else if let boxWeightString = try? container.decodeIfPresent(String.self, forKey: .boxWeight), - let boxWeightDouble = Double(boxWeightString) { + else if let boxWeightDouble = container.failsafeDecodeIfPresent(targetType: Double.self, + forKey: .boxWeightAlternate, + alternativeTypes: [weightTransformer]) { boxWeight = boxWeightDouble } @@ -93,5 +98,6 @@ extension WooShippingCustomPackage: Codable { case type case dimensions case boxWeight + case boxWeightAlternate = "box_weight" } } diff --git a/Networking/Networking/Model/ShippingLabel/Packages/Predefined package/WooShippingPredefinedOption.swift b/Networking/Networking/Model/ShippingLabel/Packages/Predefined package/WooShippingPredefinedOption.swift index 5d7f76ccb80..baa82e93468 100644 --- a/Networking/Networking/Model/ShippingLabel/Packages/Predefined package/WooShippingPredefinedOption.swift +++ b/Networking/Networking/Model/ShippingLabel/Packages/Predefined package/WooShippingPredefinedOption.swift @@ -26,6 +26,11 @@ public struct WooShippingPredefinedOption: Equatable, GeneratedFakeable { public struct WooShippingCarrierPredefinedOptions: Equatable, GeneratedFakeable { public let carrierID: String public let predefinedOptions: [WooShippingPredefinedOption] + + public init(carrierID: String, predefinedOptions: [WooShippingPredefinedOption]) { + self.carrierID = carrierID + self.predefinedOptions = predefinedOptions + } } // MARK: Decodable diff --git a/Networking/Networking/Model/ShippingLabel/Packages/WooShippingOriginAddress.swift b/Networking/Networking/Model/ShippingLabel/Packages/WooShippingOriginAddress.swift new file mode 100644 index 00000000000..1e0d0997e07 --- /dev/null +++ b/Networking/Networking/Model/ShippingLabel/Packages/WooShippingOriginAddress.swift @@ -0,0 +1,105 @@ +import Foundation +import Codegen + +public struct WooShippingOriginAddress: Identifiable, Equatable, GeneratedFakeable, GeneratedCopiable { + public let id: String + public let company: String + public let address1: String + public let address2: String + public let city: String + public let state: String + public let postcode: String + public let country: String + public let phone: String + public let firstName: String + public let lastName: String + public let email: String + public let defaultAddress: Bool + public let isVerified: Bool + + public init(id: String, + company: String, + address1: String, + address2: String, + city: String, + state: String, + postcode: String, + country: String, + phone: String, + firstName: String, + lastName: String, + email: String, + defaultAddress: + Bool, + isVerified: Bool) { + self.id = id + self.company = company + self.address1 = address1 + self.address2 = address2 + self.city = city + self.state = state + self.postcode = postcode + self.country = country + self.phone = phone + self.firstName = firstName + self.lastName = lastName + self.email = email + self.defaultAddress = defaultAddress + self.isVerified = isVerified + } +} + +// MARK: Decodable +extension WooShippingOriginAddress: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let id = try container.decode(String.self, forKey: CodingKeys.id) + let company = try container.decodeIfPresent(String.self, forKey: CodingKeys.company) ?? "" + let address1 = try container.decodeIfPresent(String.self, forKey: CodingKeys.address1) ?? "" + let address2 = try container.decodeIfPresent(String.self, forKey: CodingKeys.address2) ?? "" + let city = try container.decodeIfPresent(String.self, forKey: CodingKeys.city) ?? "" + let state = try container.decodeIfPresent(String.self, forKey: CodingKeys.state) ?? "" + let postcode = try container.decodeIfPresent(String.self, forKey: CodingKeys.postcode) ?? "" + let country = try container.decodeIfPresent(String.self, forKey: CodingKeys.country) ?? "" + let phone = try container.decodeIfPresent(String.self, forKey: CodingKeys.phone) ?? "" + let firstName = try container.decodeIfPresent(String.self, forKey: CodingKeys.firstName) ?? "" + let lastName = try container.decodeIfPresent(String.self, forKey: CodingKeys.lastName) ?? "" + let email = try container.decodeIfPresent(String.self, forKey: CodingKeys.email) ?? "" + + let defaultAddress = try container.decodeIfPresent(Bool.self, forKey: CodingKeys.defaultAddress) ?? false + let isVerified = try container.decodeIfPresent(Bool.self, forKey: CodingKeys.isVerified) ?? false + + self.init(id: id, + company: company, + address1: address1, + address2: address2, + city: city, + state: state, + postcode: postcode, + country: country, + phone: phone, + firstName: firstName, + lastName: lastName, + email: email, + defaultAddress: defaultAddress, + isVerified: isVerified) + } + + private enum CodingKeys: String, CodingKey { + case id + case company + case address1 = "address_1" + case address2 = "address_2" + case city + case state + case postcode + case country + case phone + case firstName = "first_name" + case lastName = "last_name" + case email + case defaultAddress = "default_address" + case isVerified = "is_verified" + } +} diff --git a/Networking/Networking/Model/ShippingLabel/Packages/WooShippingPackagesResponse.swift b/Networking/Networking/Model/ShippingLabel/Packages/WooShippingPackagesResponse.swift index 968cb9d4c39..903aa988e55 100644 --- a/Networking/Networking/Model/ShippingLabel/Packages/WooShippingPackagesResponse.swift +++ b/Networking/Networking/Model/ShippingLabel/Packages/WooShippingPackagesResponse.swift @@ -1,12 +1,11 @@ import Foundation import Codegen -/// Represents store options, a list of saved Shipping Label Packages (custom and predefined) for the WooCommerce Shipping extension. +/// Represents a list of available Shipping Label Packages (custom and predefined) for the WooCommerce Shipping extension. /// public struct WooShippingPackagesResponse: Equatable, GeneratedFakeable, GeneratedCopiable { - /// Store options - public let storeOptions: ShippingLabelStoreOptions + public let siteID: Int64 /// Saved custom packages public let customPackages: [WooShippingCustomPackage] @@ -17,11 +16,11 @@ public struct WooShippingPackagesResponse: Equatable, GeneratedFakeable, Generat /// All predefined options public let allPredefinedOptions: [WooShippingCarrierPredefinedOptions] - public init(storeOptions: ShippingLabelStoreOptions, + public init(siteID: Int64, customPackages: [WooShippingCustomPackage], savedPredefinedPackages: [WooShippingSavedPredefinedPackage], allPredefinedOptions: [WooShippingCarrierPredefinedOptions]) { - self.storeOptions = storeOptions + self.siteID = siteID self.customPackages = customPackages self.savedPredefinedPackages = savedPredefinedPackages self.allPredefinedOptions = allPredefinedOptions @@ -31,9 +30,12 @@ public struct WooShippingPackagesResponse: Equatable, GeneratedFakeable, Generat // MARK: Decodable extension WooShippingPackagesResponse: Decodable { public init(from decoder: Decoder) throws { + guard let siteID = decoder.userInfo[.siteID] as? Int64 else { + throw WooShippingPackagesDecodingError.missingSiteID + } + let container = try decoder.container(keyedBy: CodingKeys.self) - let storeOptions = try container.decode(ShippingLabelStoreOptions.self, forKey: .storeOptions) let packagesData = try container.nestedContainer(keyedBy: PackagesKeys.self, forKey: .packages) let savedPackagesData = try packagesData.nestedContainer(keyedBy: SavedPackagesKeys.self, forKey: .saved) @@ -60,11 +62,16 @@ extension WooShippingPackagesResponse: Decodable { providerOptions.append(option) allSavedPredefinedPackages.append(contentsOf: WooShippingPackagesResponse.savedPackages(savedOptions: savedPredefinedOptions, option: option)) }) + providerOptions.sort { $0.title < $1.title } allPredefinedOptions.append(WooShippingCarrierPredefinedOptions(carrierID: key, predefinedOptions: providerOptions)) } } - self.init(storeOptions: storeOptions, + // sort to make sure they are always in same order + // since we get the carriers data as a dictionary (key is carrier id) + allPredefinedOptions.sort { $0.carrierID < $1.carrierID } + + self.init(siteID: siteID, customPackages: customPackages, savedPredefinedPackages: allSavedPredefinedPackages, allPredefinedOptions: allPredefinedOptions) @@ -93,7 +100,6 @@ extension WooShippingPackagesResponse: Decodable { private enum CodingKeys: String, CodingKey { case packages case predefined - case storeOptions } private enum SavedPackagesKeys: String, CodingKey { @@ -106,3 +112,10 @@ extension WooShippingPackagesResponse: Decodable { case saved } } + + +// MARK: - Decoding Errors +// +enum WooShippingPackagesDecodingError: Error { + case missingSiteID +} diff --git a/Networking/Networking/Model/Site.swift b/Networking/Networking/Model/Site.swift index a7354336c1c..ff55c869355 100644 --- a/Networking/Networking/Model/Site.swift +++ b/Networking/Networking/Model/Site.swift @@ -3,7 +3,7 @@ import Codegen /// Represents a WordPress.com Site. /// -public struct Site: Decodable, Equatable, GeneratedFakeable, GeneratedCopiable { +public struct Site: Decodable, Equatable, Hashable, GeneratedFakeable, GeneratedCopiable { /// WordPress.com Site Identifier. /// diff --git a/Networking/Networking/Model/SystemStatus.swift b/Networking/Networking/Model/SystemStatus.swift index 7150bd32277..59cc3729b4e 100644 --- a/Networking/Networking/Model/SystemStatus.swift +++ b/Networking/Networking/Model/SystemStatus.swift @@ -4,39 +4,15 @@ public struct SystemStatus: Decodable { public let activePlugins: [SystemPlugin] public let inactivePlugins: [SystemPlugin] public let environment: Environment? - public let database: Database? - public let dropinPlugins: [DropinMustUsePlugin] - public let mustUsePlugins: [DropinMustUsePlugin] - public let theme: Theme? - public let settings: Settings? - public let pages: [Page] - public let postTypeCounts: [PostTypeCount] - public let security: Security? public init( activePlugins: [SystemPlugin], inactivePlugins: [SystemPlugin], - environment: Environment?, - database: Database?, - dropinPlugins: [DropinMustUsePlugin], - mustUsePlugins: [DropinMustUsePlugin], - theme: Theme?, - settings: Settings?, - pages: [Page], - postTypeCounts: [PostTypeCount], - security: Security? + environment: Environment? ) { self.activePlugins = activePlugins self.inactivePlugins = inactivePlugins self.environment = environment - self.database = database - self.dropinPlugins = dropinPlugins - self.mustUsePlugins = mustUsePlugins - self.theme = theme - self.settings = settings - self.pages = pages - self.postTypeCounts = postTypeCounts - self.security = security } /// The public initializer for System Status. @@ -46,50 +22,30 @@ public struct SystemStatus: Decodable { let activePlugins = try container.decode([SystemPlugin].self, forKey: .activePlugins) let inactivePlugins = try container.decode([SystemPlugin].self, forKey: .inactivePlugins) let environment = try container.decodeIfPresent(Environment.self, forKey: .environment) - let database = try container.decodeIfPresent(Database.self, forKey: .database) - - let dropinMustUsePlugins = try? container.nestedContainer(keyedBy: DropinMustUserPluginsCodingKeys.self, forKey: .dropinMustUsePlugins) - let dropinPlugins = try dropinMustUsePlugins?.decodeIfPresent([DropinMustUsePlugin].self, forKey: .dropins) ?? [] - let mustUsePlugins = try dropinMustUsePlugins?.decodeIfPresent([DropinMustUsePlugin].self, forKey: .mustUsePlugins) ?? [] - - let theme = try container.decodeIfPresent(Theme.self, forKey: .theme) - let settings = try container.decodeIfPresent(Settings.self, forKey: .settings) - let pages = try container.decodeIfPresent([Page].self, forKey: .pages) ?? [] - let postTypeCounts = try container.decodeIfPresent([PostTypeCount].self, forKey: .postTypeCounts) ?? [] - let security = try container.decodeIfPresent(Security.self, forKey: .security) self.init( activePlugins: activePlugins, inactivePlugins: inactivePlugins, - environment: environment, - database: database, - dropinPlugins: dropinPlugins, - mustUsePlugins: mustUsePlugins, - theme: theme, - settings: settings, - pages: pages, - postTypeCounts: postTypeCounts, - security: security + environment: environment ) } } +public extension SystemStatus { + /// Simplified Environment type that only contains storeID + struct Environment: Decodable { + public let storeID: String? + + enum CodingKeys: String, CodingKey { + case storeID = "store_id" + } + } +} + private extension SystemStatus { enum CodingKeys: String, CodingKey { case activePlugins = "active_plugins" case inactivePlugins = "inactive_plugins" - case dropinMustUsePlugins = "dropins_mu_plugins" case environment - case database - case theme - case settings - case pages - case postTypeCounts = "post_type_counts" - case security - } - - enum DropinMustUserPluginsCodingKeys: String, CodingKey { - case dropins - case mustUsePlugins = "mu_plugins" } } diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Database.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Database.swift similarity index 96% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Database.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Database.swift index c70bd46c85f..1bcaa0021ce 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Database.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Database.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Subtype for details about database in system status. /// struct Database: Decodable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+DropinMustUsePlugin.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+DropinMustUsePlugin.swift similarity index 86% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+DropinMustUsePlugin.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+DropinMustUsePlugin.swift index 0a645c2142d..9b6860bd74b 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+DropinMustUsePlugin.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+DropinMustUsePlugin.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Detail about drop-in / must-use plugin, which has minimal details compared to SystemPlugin /// struct DropinMustUsePlugin: Decodable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Environment.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Environment.swift similarity index 96% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Environment.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Environment.swift index b1c19da726c..64af87ecf30 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Environment.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Environment.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Subtype for details about environment in system status. /// struct Environment: Decodable { @@ -36,7 +36,7 @@ public extension SystemStatus { } } -private extension SystemStatus.Environment { +private extension SystemStatusReport.Environment { enum CodingKeys: String, CodingKey { case homeURL = "home_url" case siteURL = "site_url" diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Page.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Page.swift similarity index 98% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Page.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Page.swift index 4c4cf9cb0f3..0c83bf20998 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Page.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Page.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Subtype for details about a site's pages in system status. /// struct Page: Decodable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+PostTypeCount.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+PostTypeCount.swift similarity index 82% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+PostTypeCount.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+PostTypeCount.swift index da91ea0a788..a9eb152c754 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+PostTypeCount.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+PostTypeCount.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Subtype for details about post types and count in system status. /// struct PostTypeCount: Decodable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Security.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Security.swift similarity index 90% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Security.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Security.swift index 9ab18895884..be6de26be8b 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Security.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Security.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Details about security of a store in its system status report. /// struct Security: Codable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Settings.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Settings.swift similarity index 97% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Settings.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Settings.swift index 7ac3e470a18..f080ed01db1 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Settings.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Settings.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Details about a store's settings in its system status report. /// struct Settings: Decodable { diff --git a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Theme.swift b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Theme.swift similarity index 96% rename from Networking/Networking/Model/SystemStatusDetails/SystemStatus+Theme.swift rename to Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Theme.swift index 73ddd941eb5..0a44cdf7406 100644 --- a/Networking/Networking/Model/SystemStatusDetails/SystemStatus+Theme.swift +++ b/Networking/Networking/Model/SystemStatusDetails/SystemStatusReport+Theme.swift @@ -1,6 +1,6 @@ import Foundation -public extension SystemStatus { +public extension SystemStatusReport { /// Details about a store's theme in its system status report. /// struct Theme: Decodable { diff --git a/Networking/Networking/Model/SystemStatusReport.swift b/Networking/Networking/Model/SystemStatusReport.swift new file mode 100644 index 00000000000..1c91f24f331 --- /dev/null +++ b/Networking/Networking/Model/SystemStatusReport.swift @@ -0,0 +1,97 @@ +import Foundation + +/// Represent a System Status Report. +/// +public struct SystemStatusReport: Decodable { + public let activePlugins: [SystemPlugin] + public let inactivePlugins: [SystemPlugin] + public let environment: Environment? + public let database: Database? + public let dropinPlugins: [DropinMustUsePlugin] + public let mustUsePlugins: [DropinMustUsePlugin] + public let theme: Theme? + public let settings: Settings? + public let pages: [Page] + public let postTypeCounts: [PostTypeCount] + public let security: Security? + + public init( + activePlugins: [SystemPlugin], + inactivePlugins: [SystemPlugin], + environment: Environment?, + database: Database?, + dropinPlugins: [DropinMustUsePlugin], + mustUsePlugins: [DropinMustUsePlugin], + theme: Theme?, + settings: Settings?, + pages: [Page], + postTypeCounts: [PostTypeCount], + security: Security? + ) { + self.activePlugins = activePlugins + self.inactivePlugins = inactivePlugins + self.environment = environment + self.database = database + self.dropinPlugins = dropinPlugins + self.mustUsePlugins = mustUsePlugins + self.theme = theme + self.settings = settings + self.pages = pages + self.postTypeCounts = postTypeCounts + self.security = security + } + + /// The public initializer for System Status Report. + /// + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let activePlugins = try container.decode([SystemPlugin].self, forKey: .activePlugins) + let inactivePlugins = try container.decode([SystemPlugin].self, forKey: .inactivePlugins) + let environment = try container.decodeIfPresent(Environment.self, forKey: .environment) + let database = try container.decodeIfPresent(Database.self, forKey: .database) + + let dropinMustUsePlugins = try? container.nestedContainer(keyedBy: DropinMustUserPluginsCodingKeys.self, forKey: .dropinMustUsePlugins) + let dropinPlugins = try dropinMustUsePlugins?.decodeIfPresent([DropinMustUsePlugin].self, forKey: .dropins) ?? [] + let mustUsePlugins = try dropinMustUsePlugins?.decodeIfPresent([DropinMustUsePlugin].self, forKey: .mustUsePlugins) ?? [] + + let theme = try container.decodeIfPresent(Theme.self, forKey: .theme) + let settings = try container.decodeIfPresent(Settings.self, forKey: .settings) + let pages = try container.decodeIfPresent([Page].self, forKey: .pages) ?? [] + let postTypeCounts = try container.decodeIfPresent([PostTypeCount].self, forKey: .postTypeCounts) ?? [] + let security = try container.decodeIfPresent(Security.self, forKey: .security) + + self.init( + activePlugins: activePlugins, + inactivePlugins: inactivePlugins, + environment: environment, + database: database, + dropinPlugins: dropinPlugins, + mustUsePlugins: mustUsePlugins, + theme: theme, + settings: settings, + pages: pages, + postTypeCounts: postTypeCounts, + security: security + ) + } +} + +private extension SystemStatusReport { + enum CodingKeys: String, CodingKey { + case activePlugins = "active_plugins" + case inactivePlugins = "inactive_plugins" + case dropinMustUsePlugins = "dropins_mu_plugins" + case environment + case database + case theme + case settings + case pages + case postTypeCounts = "post_type_counts" + case security + } + + enum DropinMustUserPluginsCodingKeys: String, CodingKey { + case dropins + case mustUsePlugins = "mu_plugins" + } +} diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 2003362b9c9..8089bd61d91 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -84,6 +84,22 @@ public class AlamofireNetwork: Network { } } + public func responseDataAndHeaders(for request: URLRequestConvertible) async throws -> (Data, ResponseHeaders?) { + let request = requestConverter.convert(request) + let sessionRequest = alamofireSession.request(request) + .validateIfRestRequest(for: request) + let response = await sessionRequest.serializingData().response + if let error = response.networkingError { + throw error + } + switch response.result { + case .success(let data): + return (data, response.response?.headers.dictionary) + case .failure(let error): + throw error + } + } + /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. /// Only one value will be emitted and the request cannot be retried. /// diff --git a/Networking/Networking/Network/MockNetwork.swift b/Networking/Networking/Network/MockNetwork.swift index 613d769bae3..c0455b34cad 100644 --- a/Networking/Networking/Network/MockNetwork.swift +++ b/Networking/Networking/Network/MockNetwork.swift @@ -27,6 +27,9 @@ class MockNetwork: Network { /// var requestsForResponseData = [URLRequestConvertible]() + /// Response headers to be returned with the response data. + var responseHeaders: [String: String]? + /// Number of notification objects in notifications-load-all.json file. /// static let notificationLoadAllJSONCount = 46 @@ -78,6 +81,20 @@ class MockNetwork: Network { completion(.success(data)) } + func responseDataAndHeaders(for request: any URLRequestConvertible) async throws -> (Data, ResponseHeaders?) { + requestsForResponseData.append(request) + + if let error = error(for: request) { + throw error + } + + guard let name = filename(for: request), let data = Loader.contentsOf(name) else { + throw NetworkError.notFound() + } + + return (data, responseHeaders) + } + func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { requestsForResponseData.append(request) diff --git a/Networking/Networking/Network/Network.swift b/Networking/Networking/Network/Network.swift index 61e5234530b..86f09451dad 100644 --- a/Networking/Networking/Network/Network.swift +++ b/Networking/Networking/Network/Network.swift @@ -18,6 +18,7 @@ public protocol MultipartFormData { /// Unit Testing target, and inject mocked up responses. /// public protocol Network { + typealias ResponseHeaders = [String: String] var session: URLSession { get } @@ -39,6 +40,8 @@ public protocol Network { func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) + func responseDataAndHeaders(for request: URLRequestConvertible) async throws -> (Data, ResponseHeaders?) + /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. /// /// - Parameters: diff --git a/Networking/Networking/Network/NullNetwork.swift b/Networking/Networking/Network/NullNetwork.swift index f0b7fd989cc..5ad4646a0b9 100644 --- a/Networking/Networking/Network/NullNetwork.swift +++ b/Networking/Networking/Network/NullNetwork.swift @@ -19,6 +19,10 @@ public final class NullNetwork: Network { } + public func responseDataAndHeaders(for request: any URLRequestConvertible) async throws -> (Data, ResponseHeaders?) { + throw NetworkError.notFound() + } + public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { Empty, Never>().eraseToAnyPublisher() } diff --git a/Networking/Networking/Network/WordPressOrgNetwork.swift b/Networking/Networking/Network/WordPressOrgNetwork.swift index fc413d44c45..fa7f5a6ad62 100644 --- a/Networking/Networking/Network/WordPressOrgNetwork.swift +++ b/Networking/Networking/Network/WordPressOrgNetwork.swift @@ -104,6 +104,22 @@ public final class WordPressOrgNetwork: Network { } } + public func responseDataAndHeaders(for request: URLRequestConvertible) async throws -> (Data, ResponseHeaders?) { + let sessionRequest = alamofireSession.request(request).validate() + let response = await sessionRequest.serializingData().response + do { + try validateResponse(response.data) + switch response.result { + case .success(let data): + return (data, response.response?.headers.dictionary) + case .failure(let error): + throw error + } + } catch { + throw error + } + } + /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. /// Only one value will be emitted and the request cannot be retried. /// diff --git a/Networking/Networking/Remote/AccountRemote.swift b/Networking/Networking/Remote/AccountRemote.swift index 409386db1c7..e32662e0855 100644 --- a/Networking/Networking/Remote/AccountRemote.swift +++ b/Networking/Networking/Remote/AccountRemote.swift @@ -1,4 +1,5 @@ import Combine +import Alamofire import Foundation /// Protocol for `AccountRemote` mainly used for mocking. @@ -15,6 +16,8 @@ public protocol AccountRemoteProtocol { func loadSitePlan(for siteID: Int64, completion: @escaping (Result) -> Void) func loadUsernameSuggestions(from text: String) async throws -> [String] + func updateNotificationSettings(with settings: NotificationSettings) async throws + /// Creates a WPCOM account with the given email and password. /// - Parameters: /// - email: user input email. @@ -144,6 +147,13 @@ public class AccountRemote: Remote, AccountRemoteProtocol { return suggestions } + public func updateNotificationSettings(with settings: NotificationSettings) async throws { + let path = Path.notificationSettings + let parameters = try settings.toDictionary() + let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .post, path: path, parameters: parameters, encoding: JSONEncoding.default) + return try await enqueue(request) + } + public func createAccount(email: String, username: String, password: String, @@ -201,6 +211,7 @@ private extension AccountRemote { static let usernameSuggestions = "users/username/suggestions" static let accountCreation = "users/new" static let closeAccount = "me/account/close" + static let notificationSettings = "me/notifications/settings" } } diff --git a/Networking/Networking/Remote/MediaRemote.swift b/Networking/Networking/Remote/MediaRemote.swift index f32d5593c98..0d02950a91d 100644 --- a/Networking/Networking/Remote/MediaRemote.swift +++ b/Networking/Networking/Remote/MediaRemote.swift @@ -5,19 +5,12 @@ public protocol MediaRemoteProtocol { func loadMedia(siteID: Int64, mediaID: Int64, completion: @escaping (Result) -> Void) - func loadMediaLibrary(for siteID: Int64, + func loadMediaLibrary(siteID: Int64, productID: Int64?, imagesOnly: Bool, pageNumber: Int, pageSize: Int, - context: String?, - completion: @escaping (Result<[Media], Error>) -> Void) - func loadMediaLibraryFromWordPressSite(siteID: Int64, - productID: Int64?, - imagesOnly: Bool, - pageNumber: Int, - pageSize: Int, - completion: @escaping (Result<[WordPressMedia], Error>) -> Void) + completion: @escaping (Result<[WordPressMedia], Error>) -> Void) func uploadMedia(siteID: Int64, productID: Int64, mediaItem: UploadableMedia, @@ -25,11 +18,7 @@ public protocol MediaRemoteProtocol { func updateProductID(siteID: Int64, productID: Int64, mediaID: Int64, - completion: @escaping (Result) -> Void) - func updateProductIDToWordPressSite(siteID: Int64, - productID: Int64, - mediaID: Int64, - completion: @escaping (Result) -> Void) + completion: @escaping (Result) -> Void) } /// Media: Remote Endpoints @@ -65,8 +54,8 @@ public class MediaRemote: Remote, MediaRemoteProtocol { } } - /// Loads an array of media from the site's WP Media Library. - /// API reference: https://developer.wordpress.com/docs/api/1.2/get/sites/%24site/media/ + /// Loads an array of media from the site's WP Media Library via WordPress site API. + /// API reference: https://developer.wordpress.org/rest-api/reference/media/#list-media /// /// - Parameters: /// - siteID: Site for which we'll load the media from. @@ -75,49 +64,12 @@ public class MediaRemote: Remote, MediaRemoteProtocol { /// - pageNumber: The index of the page of media data to load from, starting from 1. /// - pageSize: The number of media items to return. /// - completion: Closure to be executed upon completion. - /// - public func loadMediaLibrary(for siteID: Int64, + public func loadMediaLibrary(siteID: Int64, productID: Int64? = nil, imagesOnly: Bool, pageNumber: Int = Default.pageNumber, pageSize: Int = 25, - context: String? = Default.context, - completion: @escaping (Result<[Media], Error>) -> Void) { - let parameters: [String: Any] = [ - ParameterKey.contextKey: context ?? Default.context, - ParameterKey.dotComPageSize: pageSize, - ParameterKey.pageNumber: pageNumber, - ParameterKey.fields: "ID,date,URL,thumbnails,title,alt,extension,mime_type,file", - ParameterKey.mimeType: imagesOnly ? "image" : nil, - ParameterKey.postID: productID - ].compactMapValues { $0 } - - let path = "sites/\(siteID)/media" - let request = DotcomRequest(wordpressApiVersion: .mark1_1, - method: .get, - path: path, - parameters: parameters) - let mapper = MediaListMapper() - - enqueue(request, mapper: mapper, completion: completion) - } - - /// Loads an array of media from the site's WP Media Library via WordPress site API. - /// API reference: https://developer.wordpress.org/rest-api/reference/media/#list-media - /// - /// - Parameters: - /// - siteID: Site for which we'll load the media from. - /// - productID: Loads media attached to a specific product ID. Loads all media if nil. - /// - imagesOnly: Whether only images should be loaded. - /// - pageNumber: The index of the page of media data to load from, starting from 1. - /// - pageSize: The number of media items to return. - /// - completion: Closure to be executed upon completion. - public func loadMediaLibraryFromWordPressSite(siteID: Int64, - productID: Int64? = nil, - imagesOnly: Bool, - pageNumber: Int = Default.pageNumber, - pageSize: Int = 25, - completion: @escaping (Result<[WordPressMedia], Error>) -> Void) { + completion: @escaping (Result<[WordPressMedia], Error>) -> Void) { let parameters: [String: Any] = [ ParameterKey.dotOrgPageSize: pageSize, ParameterKey.pageNumber: pageNumber, @@ -182,31 +134,6 @@ public class MediaRemote: Remote, MediaRemoteProtocol { } } - /// Sets the provided `productID` as `parent_id` of the `media`. - /// - /// API reference: https://developer.wordpress.com/docs/api/1.1/post/sites/%24site/media/%24media_ID/ - /// - /// - Parameters: - /// - siteID: Site in which the media was uploaded to. - /// - productID: Product ID to use as `parent_id` of the media. - /// - mediaID: ID of media for which `parent_id` needs to be updated. - /// - completion: Closure to be executed upon completion. - /// - public func updateProductID(siteID: Int64, - productID: Int64, - mediaID: Int64, - completion: @escaping (Result) -> Void) { - let formParameters: [String: String] = [ - ParameterKey.wordPressMediaParentID: "\(productID)", - ParameterKey.fieldsWordPressSite: ParameterValue.wordPressMediaFields, - ] - let path = "sites/\(siteID)/media/\(mediaID)" - let request = DotcomRequest(wordpressApiVersion: .mark1_1, method: .post, path: path, parameters: formParameters) - let mapper = MediaMapper() - - enqueue(request, mapper: mapper, completion: completion) - } - /// Sets the provided `productID` as post ID of the Media in WordPress site using WordPress site API /// /// API reference: to the WordPress site.via WordPress site API @@ -218,10 +145,10 @@ public class MediaRemote: Remote, MediaRemoteProtocol { /// - mediaID: ID of media for which post ID needs to be updated. /// - completion: Closure to be executed upon completion. /// - public func updateProductIDToWordPressSite(siteID: Int64, - productID: Int64, - mediaID: Int64, - completion: @escaping (Result) -> Void) { + public func updateProductID(siteID: Int64, + productID: Int64, + mediaID: Int64, + completion: @escaping (Result) -> Void) { let parameters: [String: String] = [ ParameterKey.wordPressMediaPostID: "\(productID)", ParameterKey.fieldsWordPressSite: ParameterValue.wordPressMediaFields, diff --git a/Networking/Networking/Remote/ProductVariationsRemote.swift b/Networking/Networking/Remote/ProductVariationsRemote.swift index c92ba438373..76c1085fc75 100644 --- a/Networking/Networking/Remote/ProductVariationsRemote.swift +++ b/Networking/Networking/Remote/ProductVariationsRemote.swift @@ -12,6 +12,9 @@ public protocol ProductVariationsRemoteProtocol { pageNumber: Int, pageSize: Int, completion: @escaping ([ProductVariation]?, Error?) -> Void) + func loadVariationsForPointOfSale(for siteID: Int64, + parentProductID: Int64, + pageNumber: Int) async throws -> [ProductVariation] func loadProductVariation(for siteID: Int64, productID: Int64, variationID: Int64, completion: @escaping (Result) -> Void) func createProductVariation(for siteID: Int64, productID: Int64, @@ -57,6 +60,41 @@ public class ProductVariationsRemote: Remote, ProductVariationsRemoteProtocol { pageNumber: Int = Default.pageNumber, pageSize: Int = Default.pageSize, completion: @escaping ([ProductVariation]?, Error?) -> Void) { + let request = productVariationsRequest(for: siteID, + productID: productID, + variationIDs: variationIDs, + context: context, + pageNumber: pageNumber, + pageSize: pageSize) + let mapper = ProductVariationListMapper(siteID: siteID, productID: productID) + enqueue(request, mapper: mapper, completion: completion) + } + + /// Retrieves all of the `ProductVariation`s available in POS. + /// - Parameters: + /// - siteID: Site for which we'll fetch remote product variations. + /// - parentProductID: Product for which we'll fetch remote product variations. + /// - pageNumber: Number of page that should be retrieved. + /// - Returns: Variations for the provided parent product. + public func loadVariationsForPointOfSale(for siteID: Int64, + parentProductID: Int64, + pageNumber: Int = Default.pageNumber) async throws -> [ProductVariation] { + let request = productVariationsRequest(for: siteID, + productID: parentProductID, + variationIDs: [], + context: nil, + pageNumber: pageNumber, + pageSize: POSConstants.variationsPerPage) + let mapper = ProductVariationListMapper(siteID: siteID, productID: parentProductID) + return try await enqueue(request, mapper: mapper) + } + + private func productVariationsRequest(for siteID: Int64, + productID: Int64, + variationIDs: [Int64], + context: String?, + pageNumber: Int, + pageSize: Int) -> JetpackRequest { let stringOfVariationIDs = variationIDs.map { String($0) } .joined(separator: ",") let parameters = [ @@ -74,8 +112,7 @@ public class ProductVariationsRemote: Remote, ProductVariationsRemoteProtocol { path: path, parameters: parameters, availableAsRESTRequest: true) - let mapper = ProductVariationListMapper(siteID: siteID, productID: productID) - enqueue(request, mapper: mapper, completion: completion) + return request } /// Retrieves a specific `ProductVariation`. @@ -288,3 +325,9 @@ public extension ProductVariationsRemote { static let include: String = "include" } } + +private extension ProductVariationsRemote { + enum POSConstants { + static let variationsPerPage: Int = 100 + } +} diff --git a/Networking/Networking/Remote/ProductsRemote.swift b/Networking/Networking/Remote/ProductsRemote.swift index dbce157ad0a..1cb285f3b38 100644 --- a/Networking/Networking/Remote/ProductsRemote.swift +++ b/Networking/Networking/Remote/ProductsRemote.swift @@ -197,17 +197,22 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { enqueue(request, mapper: mapper, completion: completion) } - /// Retrieves simple products for the Point of Sale + /// Retrieves products for the Point of Sale. Simple and variable products are loaded for WC version 9.6+, otherwise only simple products are loaded. /// /// - Parameters: /// - siteID: Site for which we'll fetch remote products. + /// - productTypes: A list of product types to be included in the results. /// - pageNumber: Number of page that should be retrieved. /// - public func loadSimpleProductsForPointOfSale(for siteID: Int64, pageNumber: Int = 1) async throws -> [Product] { + public func loadProductsForPointOfSale(for siteID: Int64, + productTypes: [ProductType] = [.simple], + pageNumber: Int = 1) async throws -> PagedItems { let parameters = [ ParameterKey.page: String(pageNumber), ParameterKey.perPage: POSConstants.productsPerPage, + // When both productType and productTypes are provided, the productType is ignored in WC versions 9.6+. ParameterKey.productType: POSConstants.productType, + ParameterKey.productTypes: productTypes.map { $0.rawValue }.joined(separator: ","), ParameterKey.orderBy: OrderKey.name.value, ParameterKey.order: Order.ascending.value, ParameterKey.productStatus: POSConstants.productStatus, @@ -219,7 +224,16 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { parameters: parameters, availableAsRESTRequest: true) let mapper = ProductListMapper(siteID: siteID) - return try await enqueue(request, mapper: mapper) + + let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) + + // Extracts the total number of pages from the response headers. + // Response header names are case insensitive. + let totalPages = responseHeaders?.first(where: { $0.key.lowercased() == Remote.PaginationHeaderKey.totalPagesCount.lowercased() }) + .flatMap { Int($0.value) } + let hasMorePages = totalPages.map { pageNumber < $0 } ?? true + + return .init(items: products, hasMorePages: hasMorePages) } /// Retrieves a specific list of `Product`s by `productID`. @@ -611,6 +625,7 @@ public extension ProductsRemote { static let globalUniqueID: String = "global_unique_id" static let productStatus: String = "status" static let productType: String = "type" + static let productTypes: String = "include_types" static let stockStatus: String = "stock_status" static let category: String = "category" static let fields: String = "_fields" diff --git a/Networking/Networking/Remote/Remote.swift b/Networking/Networking/Remote/Remote.swift index ea0c01f9038..0e023a16891 100644 --- a/Networking/Networking/Remote/Remote.swift +++ b/Networking/Networking/Remote/Remote.swift @@ -258,8 +258,33 @@ public class Remote: NSObject { } } } + + func enqueueWithResponseHeaders(_ request: Request, mapper: M) async throws -> (data: M.Output, headers: [String: String]?) { + do { + let (data, headers) = try await network.responseDataAndHeaders(for: request) + let validator = request.responseDataValidator() + let parsedData = try validateAndParseData(data, request: request, validator: validator, mapper: mapper) + return (data: parsedData, headers: headers) + } catch { + handleResponseError(error: error, for: request) + throw mapNetworkError(error: error, for: request) + } + } } +private extension Remote { + // Validation and parsing of the response data is separated so that the decoding error can be handled separately from network error. + func validateAndParseData(_ data: Data, request: Request, validator: ResponseDataValidator, mapper: M) throws -> M.Output { + do { + try validator.validate(data: data) + return try mapper.map(response: data) + } catch { + DDLogError("<> Mapping Error: \(error)") + handleDecodingError(error: error, for: request, entityName: "\(M.Output.self)") + throw error + } + } +} // MARK: - Private Methods // @@ -341,6 +366,17 @@ private extension Remote { } } +/// Contains the result of a paginated request. +public struct PagedItems { + public let items: [T] + public let hasMorePages: Bool + + public init(items: [T], hasMorePages: Bool) { + self.items = items + self.hasMorePages = hasMorePages + } +} + // MARK: - Constants! // public extension Remote { @@ -349,6 +385,10 @@ public extension Remote { public static let firstPageNumber: Int = 1 } + enum PaginationHeaderKey { + static let totalPagesCount = "x-wp-totalpages" + } + enum JSONParsingErrorUserInfoKey { public static let path = "path" public static let entityName = "entity" diff --git a/Networking/Networking/Remote/SystemStatusRemote.swift b/Networking/Networking/Remote/SystemStatusRemote.swift index 3e72d2bc46c..83243004f35 100644 --- a/Networking/Networking/Remote/SystemStatusRemote.swift +++ b/Networking/Networking/Remote/SystemStatusRemote.swift @@ -38,7 +38,7 @@ public class SystemStatusRemote: Remote { /// - completion: Closure to be excuted upon completion /// public func fetchSystemStatusReport(for siteID: Int64, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { let path = Constants.systemStatusPath let request = JetpackRequest(wooApiVersion: .mark3, method: .get, @@ -46,7 +46,7 @@ public class SystemStatusRemote: Remote { path: path, parameters: nil, availableAsRESTRequest: true) - let mapper = SystemStatusMapper(siteID: siteID) + let mapper = SystemStatusReportMapper(siteID: siteID) enqueue(request, mapper: mapper, completion: completion) } } diff --git a/Networking/Networking/Remote/WooShippingRemote.swift b/Networking/Networking/Remote/WooShippingRemote.swift index 63721e3a82d..f0f11b4092b 100644 --- a/Networking/Networking/Remote/WooShippingRemote.swift +++ b/Networking/Networking/Remote/WooShippingRemote.swift @@ -5,6 +5,9 @@ public protocol WooShippingRemoteProtocol { customPackage: WooShippingCustomPackage?, predefinedOption: WooShippingPredefinedSavedOption?, completion: @escaping (Result) -> Void) + func deletePackage(siteID: Int64, + packageID: String, + completion: @escaping (Result) -> Void) func loadLabelRates(siteID: Int64, orderID: Int64, originAddress: ShippingLabelAddress, @@ -29,6 +32,9 @@ public protocol WooShippingRemoteProtocol { labelIDs: [Int64], paperSize: ShippingLabelPaperSize, completion: @escaping (Result) -> Void) + + func loadOriginAddresses(siteID: Int64, + completion: @escaping (Result<[WooShippingOriginAddress], Error>) -> Void) } /// Shipping Labels Remote Endpoints for the WooShipping Plugin. @@ -78,6 +84,27 @@ public final class WooShippingRemote: Remote, WooShippingRemoteProtocol { } } + public func deletePackage(siteID: Int64, + packageID: String, + completion: @escaping (Result) -> Void) { + do { + let path = "\(Path.packages)/\(packageID)" + + let request = JetpackRequest(wooApiVersion: .wooShipping, + method: .delete, + siteID: siteID, + path: path, + parameters: nil, + availableAsRESTRequest: true) + + let mapper = WooShippingCreatePackageMapper() + + enqueue(request, mapper: mapper, completion: completion) + } catch { + completion(.failure(error)) + } + } + /// Loads shipping rates for a given order. /// - Parameters: /// - siteID: Remote ID of the site. @@ -129,7 +156,7 @@ public final class WooShippingRemote: Remote, WooShippingRemoteProtocol { path: path, availableAsRESTRequest: true) - let mapper = WooShippingPackagesMapper() + let mapper = WooShippingPackagesMapper(siteID: siteID) enqueue(request, mapper: mapper, completion: completion) } } @@ -244,6 +271,22 @@ public final class WooShippingRemote: Remote, WooShippingRemoteProtocol { enqueue(request, mapper: mapper, completion: completion) } + + /// Loads origin addresses. + /// - Parameters: + /// - siteID: Remote ID of the site. + /// - completion: Closure to be executed upon completion. + public func loadOriginAddresses(siteID: Int64, completion: @escaping (Result<[WooShippingOriginAddress], any Error>) -> Void) { + let request = JetpackRequest(wooApiVersion: .wooShipping, + method: .get, + siteID: siteID, + path: Path.originAddresses, + parameters: nil, + availableAsRESTRequest: true) + let mapper = WooShippingOriginAddressesMapper() + + enqueue(request, mapper: mapper, completion: completion) + } } // MARK: Constants @@ -255,6 +298,7 @@ private extension WooShippingRemote { static let purchase = "label/purchase" static let status = "label/status" static let print = "label/print" + static let originAddresses = "address/origins" } enum ParameterKey { diff --git a/Networking/NetworkingTests/Mapper/SystemStatusMapperTests.swift b/Networking/NetworkingTests/Mapper/SystemStatusMapperTests.swift index 3c473ad0c65..f8b24e6f582 100644 --- a/Networking/NetworkingTests/Mapper/SystemStatusMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/SystemStatusMapperTests.swift @@ -11,152 +11,49 @@ final class SystemStatusMapperTests: XCTestCase { func test_system_status_fields_are_properly_parsed() throws { // When - let report = try mapLoadSystemStatusResponse() + let status = try mapLoadSystemStatusResponse() // Then - XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.version, "5.9.0") - XCTAssertEqual(report.environment?.wpVersion, "5.8.2") - XCTAssertEqual(report.environment?.phpVersion, "7.4.26") - XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") - XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") - - XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") - XCTAssertEqual(report.database?.databasePrefix, "wp_") - XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) - XCTAssertEqual(report.database?.databaseTables.other.count, 29) - - XCTAssertEqual(report.activePlugins.count, 4) - XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) - XCTAssertEqual(report.inactivePlugins.count, 2) - XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) - XCTAssertEqual(report.dropinPlugins.count, 2) - XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") - XCTAssertEqual(report.mustUsePlugins.count, 1) - XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") - - XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") - XCTAssertEqual(report.theme?.version, "1.4") - XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") - XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) - XCTAssertEqual(report.theme?.overrides.count, 0) - - XCTAssertEqual(report.settings?.apiEnabled, false) - XCTAssertEqual(report.settings?.currency, "USD") - XCTAssertEqual(report.settings?.currencySymbol, "$") - XCTAssertEqual(report.settings?.currencyPosition, "left") - XCTAssertEqual(report.settings?.numberOfDecimals, 2) - XCTAssertEqual(report.settings?.thousandSeparator, ",") - XCTAssertEqual(report.settings?.decimalSeparator, ".") - XCTAssertEqual(report.settings?.taxonomies["external"], "external") - XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") - - XCTAssertEqual(report.security?.secureConnection, true) - XCTAssertEqual(report.security?.hideErrors, false) - - XCTAssertEqual(report.pages.count, 5) - XCTAssertEqual(report.postTypeCounts.count, 3) + XCTAssertEqual(status.environment?.storeID, "sample-store-uuid") + XCTAssertEqual(status.activePlugins.count, 4) + XCTAssertEqual(status.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(status.inactivePlugins.count, 2) + XCTAssertEqual(status.inactivePlugins[1].siteID, dummySiteID) } func test_system_status_fields_are_properly_parsed_when_response_has_no_data_envelope() throws { // When - let report = try mapLoadSystemStatusResponseWithoutDataEnvelope() + let status = try mapLoadSystemStatusResponseWithoutDataEnvelope() // Then - XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.version, "5.9.0") - XCTAssertEqual(report.environment?.wpVersion, "5.8.2") - XCTAssertEqual(report.environment?.phpVersion, "7.4.26") - XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") - XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") - - XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") - XCTAssertEqual(report.database?.databasePrefix, "wp_") - XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) - XCTAssertEqual(report.database?.databaseTables.other.count, 29) - - XCTAssertEqual(report.activePlugins.count, 4) - XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) - XCTAssertEqual(report.inactivePlugins.count, 2) - XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) - XCTAssertEqual(report.dropinPlugins.count, 2) - XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") - XCTAssertEqual(report.mustUsePlugins.count, 1) - XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") - - XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") - XCTAssertEqual(report.theme?.version, "1.4") - XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") - XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) - XCTAssertEqual(report.theme?.overrides.count, 0) - - XCTAssertEqual(report.settings?.apiEnabled, false) - XCTAssertEqual(report.settings?.currency, "USD") - XCTAssertEqual(report.settings?.currencySymbol, "$") - XCTAssertEqual(report.settings?.currencyPosition, "left") - XCTAssertEqual(report.settings?.numberOfDecimals, 2) - XCTAssertEqual(report.settings?.thousandSeparator, ",") - XCTAssertEqual(report.settings?.decimalSeparator, ".") - XCTAssertEqual(report.settings?.taxonomies["external"], "external") - XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") - - XCTAssertEqual(report.security?.secureConnection, true) - XCTAssertEqual(report.security?.hideErrors, false) - - XCTAssertEqual(report.pages.count, 5) - XCTAssertEqual(report.postTypeCounts.count, 3) + XCTAssertEqual(status.activePlugins.count, 4) + XCTAssertEqual(status.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(status.inactivePlugins.count, 2) + XCTAssertEqual(status.inactivePlugins[1].siteID, dummySiteID) } func test_system_status_fields_are_properly_parsed_when_response_has_inconsistent_data_type_for_page_id() throws { // When - let report = try mapLoadSystemStatusResponseWithInconsistentPageIdDataType() + let status = try mapLoadSystemStatusResponseWithInconsistentPageIdDataType() // Then - XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") - XCTAssertEqual(report.environment?.version, "5.9.0") - XCTAssertEqual(report.environment?.wpVersion, "5.8.2") - XCTAssertEqual(report.environment?.phpVersion, "7.4.26") - XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") - XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") - - XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") - XCTAssertEqual(report.database?.databasePrefix, "wp_") - XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) - XCTAssertEqual(report.database?.databaseTables.other.count, 29) - - XCTAssertEqual(report.activePlugins.count, 4) - XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) - XCTAssertEqual(report.inactivePlugins.count, 2) - XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) - XCTAssertEqual(report.dropinPlugins.count, 2) - XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") - XCTAssertEqual(report.mustUsePlugins.count, 1) - XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") - - XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") - XCTAssertEqual(report.theme?.version, "1.4") - XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") - XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) - XCTAssertEqual(report.theme?.overrides.count, 0) - - XCTAssertEqual(report.settings?.apiEnabled, false) - XCTAssertEqual(report.settings?.currency, "USD") - XCTAssertEqual(report.settings?.currencySymbol, "$") - XCTAssertEqual(report.settings?.currencyPosition, "left") - XCTAssertEqual(report.settings?.numberOfDecimals, 2) - XCTAssertEqual(report.settings?.thousandSeparator, ",") - XCTAssertEqual(report.settings?.decimalSeparator, ".") - XCTAssertEqual(report.settings?.taxonomies["external"], "external") - XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") - - XCTAssertEqual(report.security?.secureConnection, true) - XCTAssertEqual(report.security?.hideErrors, false) - - XCTAssertEqual(report.pages.count, 5) - XCTAssertEqual(report.postTypeCounts.count, 3) + XCTAssertEqual(status.environment?.storeID, "sample-store-uuid") + XCTAssertEqual(status.activePlugins.count, 4) + XCTAssertEqual(status.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(status.inactivePlugins.count, 2) + XCTAssertEqual(status.inactivePlugins[1].siteID, dummySiteID) + } + + func test_system_status_fields_are_properly_parsed_when_response_has_inconsistent_data_type_for_unused_environment_properties() throws { + // When + let status = try mapLoadSystemStatusResponseWithInconsistentEnvironmentMaxUploadSizeType() + + // Then + XCTAssertEqual(status.environment?.storeID, "sample-store-uuid") + XCTAssertEqual(status.activePlugins.count, 4) + XCTAssertEqual(status.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(status.inactivePlugins.count, 2) + XCTAssertEqual(status.inactivePlugins[1].siteID, dummySiteID) } } @@ -189,4 +86,10 @@ private extension SystemStatusMapperTests { func mapLoadSystemStatusResponseWithInconsistentPageIdDataType() throws -> SystemStatus { return try mapReport(from: "systemStatus-inconsistent-page-id-data-type") } + + /// Returns the SystemStatus output upon receiving `systemStatus-inconsistent-environment-max-upload-size-data-type.json` + /// + func mapLoadSystemStatusResponseWithInconsistentEnvironmentMaxUploadSizeType() throws -> SystemStatus { + return try mapReport(from: "systemStatus-inconsistent-environment-max-upload-size-data-type") + } } diff --git a/Networking/NetworkingTests/Mapper/SystemStatusReportMapperTests.swift b/Networking/NetworkingTests/Mapper/SystemStatusReportMapperTests.swift new file mode 100644 index 00000000000..5186e9d7593 --- /dev/null +++ b/Networking/NetworkingTests/Mapper/SystemStatusReportMapperTests.swift @@ -0,0 +1,192 @@ +import XCTest +@testable import Networking + +/// SystemStatusReportMapper Unit Tests +/// +final class SystemStatusReportMapperTests: XCTestCase { + + /// Dummy Site ID. + /// + private let dummySiteID: Int64 = 999999 + + func test_system_status_report_fields_are_properly_parsed() throws { + // When + let report = try mapLoadSystemStatusResponse() + + // Then + XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.version, "5.9.0") + XCTAssertEqual(report.environment?.wpVersion, "5.8.2") + XCTAssertEqual(report.environment?.phpVersion, "7.4.26") + XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") + XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") + + XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") + XCTAssertEqual(report.database?.databasePrefix, "wp_") + XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) + XCTAssertEqual(report.database?.databaseTables.other.count, 29) + + XCTAssertEqual(report.activePlugins.count, 4) + XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(report.inactivePlugins.count, 2) + XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) + XCTAssertEqual(report.dropinPlugins.count, 2) + XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") + XCTAssertEqual(report.mustUsePlugins.count, 1) + XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") + + XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") + XCTAssertEqual(report.theme?.version, "1.4") + XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") + XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) + XCTAssertEqual(report.theme?.overrides.count, 0) + + XCTAssertEqual(report.settings?.apiEnabled, false) + XCTAssertEqual(report.settings?.currency, "USD") + XCTAssertEqual(report.settings?.currencySymbol, "$") + XCTAssertEqual(report.settings?.currencyPosition, "left") + XCTAssertEqual(report.settings?.numberOfDecimals, 2) + XCTAssertEqual(report.settings?.thousandSeparator, ",") + XCTAssertEqual(report.settings?.decimalSeparator, ".") + XCTAssertEqual(report.settings?.taxonomies["external"], "external") + XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") + + XCTAssertEqual(report.security?.secureConnection, true) + XCTAssertEqual(report.security?.hideErrors, false) + + XCTAssertEqual(report.pages.count, 5) + XCTAssertEqual(report.postTypeCounts.count, 3) + } + + func test_system_status_report_fields_are_properly_parsed_when_response_has_no_data_envelope() throws { + // When + let report = try mapLoadSystemStatusResponseWithoutDataEnvelope() + + // Then + XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.version, "5.9.0") + XCTAssertEqual(report.environment?.wpVersion, "5.8.2") + XCTAssertEqual(report.environment?.phpVersion, "7.4.26") + XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") + XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") + + XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") + XCTAssertEqual(report.database?.databasePrefix, "wp_") + XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) + XCTAssertEqual(report.database?.databaseTables.other.count, 29) + + XCTAssertEqual(report.activePlugins.count, 4) + XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(report.inactivePlugins.count, 2) + XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) + XCTAssertEqual(report.dropinPlugins.count, 2) + XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") + XCTAssertEqual(report.mustUsePlugins.count, 1) + XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") + + XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") + XCTAssertEqual(report.theme?.version, "1.4") + XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") + XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) + XCTAssertEqual(report.theme?.overrides.count, 0) + + XCTAssertEqual(report.settings?.apiEnabled, false) + XCTAssertEqual(report.settings?.currency, "USD") + XCTAssertEqual(report.settings?.currencySymbol, "$") + XCTAssertEqual(report.settings?.currencyPosition, "left") + XCTAssertEqual(report.settings?.numberOfDecimals, 2) + XCTAssertEqual(report.settings?.thousandSeparator, ",") + XCTAssertEqual(report.settings?.decimalSeparator, ".") + XCTAssertEqual(report.settings?.taxonomies["external"], "external") + XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") + + XCTAssertEqual(report.security?.secureConnection, true) + XCTAssertEqual(report.security?.hideErrors, false) + + XCTAssertEqual(report.pages.count, 5) + XCTAssertEqual(report.postTypeCounts.count, 3) + } + + func test_system_status_report_fields_are_properly_parsed_when_response_has_inconsistent_data_type_for_page_id() throws { + // When + let report = try mapLoadSystemStatusResponseWithInconsistentPageIdDataType() + + // Then + XCTAssertEqual(report.environment?.homeURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.siteURL, "https://additional-beetle.jurassic.ninja") + XCTAssertEqual(report.environment?.version, "5.9.0") + XCTAssertEqual(report.environment?.wpVersion, "5.8.2") + XCTAssertEqual(report.environment?.phpVersion, "7.4.26") + XCTAssertEqual(report.environment?.curlVersion, "7.47.0, OpenSSL/1.0.2g") + XCTAssertEqual(report.environment?.mysqlVersion, "5.7.33-0ubuntu0.16.04.1-log") + + XCTAssertEqual(report.database?.wcDatabaseVersion, "5.9.0") + XCTAssertEqual(report.database?.databasePrefix, "wp_") + XCTAssertEqual(report.database?.databaseTables.woocommerce.count, 14) + XCTAssertEqual(report.database?.databaseTables.other.count, 29) + + XCTAssertEqual(report.activePlugins.count, 4) + XCTAssertEqual(report.activePlugins[0].siteID, dummySiteID) + XCTAssertEqual(report.inactivePlugins.count, 2) + XCTAssertEqual(report.inactivePlugins[1].siteID, dummySiteID) + XCTAssertEqual(report.dropinPlugins.count, 2) + XCTAssertEqual(report.dropinPlugins[0].name, "advanced-cache.php") + XCTAssertEqual(report.mustUsePlugins.count, 1) + XCTAssertEqual(report.mustUsePlugins[0].name, "WP.com Site Helper") + + XCTAssertEqual(report.theme?.name, "Twenty Twenty-One") + XCTAssertEqual(report.theme?.version, "1.4") + XCTAssertEqual(report.theme?.authorURL, "https://wordpress.org/") + XCTAssertEqual(report.theme?.hasWoocommerceSupport, true) + XCTAssertEqual(report.theme?.overrides.count, 0) + + XCTAssertEqual(report.settings?.apiEnabled, false) + XCTAssertEqual(report.settings?.currency, "USD") + XCTAssertEqual(report.settings?.currencySymbol, "$") + XCTAssertEqual(report.settings?.currencyPosition, "left") + XCTAssertEqual(report.settings?.numberOfDecimals, 2) + XCTAssertEqual(report.settings?.thousandSeparator, ",") + XCTAssertEqual(report.settings?.decimalSeparator, ".") + XCTAssertEqual(report.settings?.taxonomies["external"], "external") + XCTAssertEqual(report.settings?.productVisibilityTerms["exclude-from-catalog"], "exclude-from-catalog") + + XCTAssertEqual(report.security?.secureConnection, true) + XCTAssertEqual(report.security?.hideErrors, false) + + XCTAssertEqual(report.pages.count, 5) + XCTAssertEqual(report.postTypeCounts.count, 3) + } +} + +private extension SystemStatusReportMapperTests { + + /// Returns the SystemStatusReportMapper output upon receiving `filename` (Data Encoded) + /// + func mapReport(from filename: String) throws -> SystemStatusReport { + guard let response = Loader.contentsOf(filename) else { + throw NetworkError.notFound() + } + + return try SystemStatusReportMapper(siteID: dummySiteID).map(response: response) + } + + /// Returns the SystemStatusReport output upon receiving `systemStatus.json` + /// + func mapLoadSystemStatusResponse() throws -> SystemStatusReport { + return try mapReport(from: "systemStatus") + } + + /// Returns the SystemStatusReport output upon receiving `systemStatus-without-data.json` + /// + func mapLoadSystemStatusResponseWithoutDataEnvelope() throws -> SystemStatusReport { + return try mapReport(from: "systemStatus-without-data") + } + + /// Returns the SystemStatusReport output upon receiving `systemStatus-inconsistent-page-id-data-type.json` + /// + func mapLoadSystemStatusResponseWithInconsistentPageIdDataType() throws -> SystemStatusReport { + return try mapReport(from: "systemStatus-inconsistent-page-id-data-type") + } +} diff --git a/Networking/NetworkingTests/Remote/AccountRemoteTests.swift b/Networking/NetworkingTests/Remote/AccountRemoteTests.swift index 871e7dedc8e..3be598a5087 100644 --- a/Networking/NetworkingTests/Remote/AccountRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/AccountRemoteTests.swift @@ -272,4 +272,65 @@ final class AccountRemoteTests: XCTestCase { // Then XCTAssertEqual(expectedError, errorCaught as? NetworkError) } + + // MARK: - Notification settings + + func test_updateNotificationSettings_sends_correct_parameters() async throws { + // Given + let remote = AccountRemote(network: network) + network.simulateResponse(requestUrlSuffix: "me/notifications/settings", filename: "notification-settings") + + // When + let notificationSettings = NotificationSettings(deviceID: 58089781, enabledSites: [], disabledSites: [194373765]) + _ = try await remote.updateNotificationSettings(with: notificationSettings) + + // Then + let request = try XCTUnwrap(network.requestsForResponseData.first as? DotcomRequest) + + let actualParam = try XCTUnwrap(request.parameters?["blogs"] as? [[String: Any]]) + XCTAssertEqual(actualParam.count, 1) + XCTAssertEqual(actualParam.first?["blog_id"] as? Int64, 194373765) + + let deviceSettings = try XCTUnwrap(actualParam.first?["devices"] as? [[String: Any]]) + XCTAssertEqual(deviceSettings.first?["device_id"] as? Int64, 58089781) + XCTAssertEqual(deviceSettings.first?["new_comment"] as? Bool, false) + XCTAssertEqual(deviceSettings.first?["store_order"] as? Bool, false) + } + + func test_updateNotificationSettings_succeeds_on_request_success() async { + // Given + let remote = AccountRemote(network: network) + network.simulateResponse(requestUrlSuffix: "me/notifications/settings", filename: "notification-settings") + + // When + var errorCaught: Error? + do { + let notificationSettings = NotificationSettings(deviceID: 58089781, enabledSites: [194373765], disabledSites: []) + try await remote.updateNotificationSettings(with: notificationSettings) + } catch { + errorCaught = error + } + + // Then + XCTAssertNil(errorCaught) + } + + func test_updateNotificationSettings_relays_error_on_request_failure() async { + // Given + let remote = AccountRemote(network: network) + let expectedError = NetworkError.timeout() + network.simulateError(requestUrlSuffix: "me/notifications/settings", error: expectedError) + + // When + var errorCaught: Error? + do { + let notificationSettings = NotificationSettings(deviceID: 58089781, enabledSites: [194373765], disabledSites: []) + try await remote.updateNotificationSettings(with: notificationSettings) + } catch { + errorCaught = error + } + + // Then + XCTAssertEqual(expectedError, errorCaught as? NetworkError) + } } diff --git a/Networking/NetworkingTests/Remote/MediaRemoteTests.swift b/Networking/NetworkingTests/Remote/MediaRemoteTests.swift index 9b0f1916285..09a8a8a936f 100644 --- a/Networking/NetworkingTests/Remote/MediaRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/MediaRemoteTests.swift @@ -80,82 +80,18 @@ final class MediaRemoteTests: XCTestCase { let error = try XCTUnwrap(result.failure) XCTAssertEqual(error as? NetworkError, .notFound()) } - - // MARK: - Load Media From Media Library `loadMediaLibrary` - - func test_loadMediaLibrary_sends_mime_type_filter_if_imagesOnly_is_true() throws { - // Given - let remote = MediaRemote(network: network) - - // When - remote.loadMediaLibrary(for: self.sampleSiteID, imagesOnly: true, completion: { _ in }) - - // Then - let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) - let mimeTypeValue = try XCTUnwrap(request.parameters?["mime_type"] as? String) - XCTAssertEqual(mimeTypeValue, "image") - } - - func test_loadMediaLibrary_does_not_send_mime_type_filter_if_imagesOnly_is_false() throws { - // Given - let remote = MediaRemote(network: network) - - // When - remote.loadMediaLibrary(for: self.sampleSiteID, imagesOnly: false, completion: { _ in }) - - // Then - let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) - XCTAssertNil(request.parameters?["mime_type"]) - } + // MARK: - Load Media From Media Library `loadMediaLibrary` via WordPress Site API /// Verifies that `loadMediaLibrary` properly parses the `media-library` sample response. /// - func test_loadMediaLibrary_properly_returns_parsed_media() throws { + func test_loadMediaLibrary_properly_returns_parsed_media_list() throws { // Given let remote = MediaRemote(network: network) network.simulateResponse(requestUrlSuffix: "media", filename: "media-library") // When let result = waitFor { promise in - remote.loadMediaLibrary(for: self.sampleSiteID, imagesOnly: true) { result in - promise(result) - } - } - - // Then - let mediaItems = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaItems.count, 5) - } - - /// Verifies that `loadMediaLibrary` properly relays Networking Layer errors. - /// - func test_loadMediaLibrary_properly_relays_networking_errors() { - // Given - let remote = MediaRemote(network: network) - - // When - let result = waitFor { promise in - remote.loadMediaLibrary(for: self.sampleSiteID, imagesOnly: true) { result in - promise(result) - } - } - - // Then - XCTAssertTrue(result.isFailure) - } - - // MARK: - Load Media From Media Library `loadMediaLibrary` via WordPress Site API - - /// Verifies that `loadMediaLibraryFromWordPressSite` properly parses the `media-library-from-wordpress-site` sample response. - /// - func test_loadMediaLibraryFromWordPressSite_properly_returns_parsed_media_list() throws { - // Given - let remote = MediaRemote(network: network) - network.simulateResponse(requestUrlSuffix: "media", filename: "media-library-from-wordpress-site") - - // When - let result = waitFor { promise in - remote.loadMediaLibraryFromWordPressSite(siteID: self.sampleSiteID, imagesOnly: true) { result in + remote.loadMediaLibrary(siteID: self.sampleSiteID, imagesOnly: true) { result in promise(result) } } @@ -163,14 +99,14 @@ final class MediaRemoteTests: XCTestCase { // Then let mediaItems = try XCTUnwrap(result.get()) XCTAssertEqual(mediaItems.count, 3) - let textMedia = mediaItems[0] + let textMedia = mediaItems[2] XCTAssertEqual(textMedia.mediaID, 28) XCTAssertEqual(textMedia.slug, "xanh-3") XCTAssertEqual(textMedia.mimeType, "text/plain") XCTAssertEqual(textMedia.title?.rendered, "Xanh-3") XCTAssertEqual(textMedia.src, "https://ninja.media/wp-content/uploads/2023/12/Xanh-3.txt") - let imageMedia = mediaItems[1] + let imageMedia = mediaItems[0] XCTAssertEqual(imageMedia.mediaID, 22) XCTAssertEqual(imageMedia.date, Date(timeIntervalSince1970: 1637546157)) XCTAssertEqual(imageMedia.slug, "img_0111-2") @@ -188,15 +124,15 @@ final class MediaRemoteTests: XCTestCase { height: 150)) } - /// Verifies that `loadMediaLibraryFromWordPressSite` properly relays Networking Layer errors. + /// Verifies that `loadMediaLibrary` properly relays Networking Layer errors. /// - func test_loadMediaLibraryFromWordPressSite_properly_relays_networking_errors() throws { + func test_loadMediaLibrary_properly_relays_networking_errors() throws { // Given let remote = MediaRemote(network: network) // When let result = waitFor { promise in - remote.loadMediaLibraryFromWordPressSite(siteID: self.sampleSiteID, imagesOnly: true) { result in + remote.loadMediaLibrary(siteID: self.sampleSiteID, imagesOnly: true) { result in promise(result) } } @@ -276,13 +212,13 @@ final class MediaRemoteTests: XCTestCase { // MARK: - updateProductID - /// Verifies that `updateProductID` properly parses the `media-update-product-id` sample response. + /// Verifies that `updateProductID` properly parses the `media-update-product-id-in-wordpress-site` sample response. /// func test_updateProductID_properly_returns_parsed_media() throws { // Given let remote = MediaRemote(network: network) let path = "sites/\(sampleSiteID)/media/\(sampleMediaID)" - network.simulateResponse(requestUrlSuffix: path, filename: "media-update-product-id") + network.simulateResponse(requestUrlSuffix: path, filename: "media-update-product-id-in-wordpress-site") // When let result = waitFor { promise in @@ -317,106 +253,33 @@ final class MediaRemoteTests: XCTestCase { XCTAssertTrue(result.isFailure) } - // MARK: - updateProductIDToWordPressSite - - /// Verifies that `updateProductIDToWordPressSite` properly parses the `media-update-product-id-in-wordpress-site` sample response. - /// - func test_updateProductIDToWordPressSite_properly_returns_parsed_media() throws { - // Given - let remote = MediaRemote(network: network) - let path = "sites/\(sampleSiteID)/media/\(sampleMediaID)" - network.simulateResponse(requestUrlSuffix: path, filename: "media-update-product-id-in-wordpress-site") - - // When - let result = waitFor { promise in - remote.updateProductIDToWordPressSite(siteID: self.sampleSiteID, - productID: self.sampleProductID, - mediaID: self.sampleMediaID) { result in - promise(result) - } - } - - // Then - let media = try XCTUnwrap(result.get()) - XCTAssertEqual(media.mediaID, sampleMediaID) - } - - /// Verifies that `updateProductIDToWordPressSite` properly relays Networking Layer errors. - /// - func test_updateProductIDToWordPressSite_properly_relays_networking_errors() { - // Given - let remote = MediaRemote(network: network) - - // When - let result = waitFor { promise in - remote.updateProductIDToWordPressSite(siteID: self.sampleSiteID, - productID: self.sampleProductID, - mediaID: self.sampleMediaID) { result in - promise(result) - } - } - - // Then - XCTAssertTrue(result.isFailure) - } - // MARK: - Loading media for specific product ID - func test_loadMediaLibrary_sends_postID_filter_if_productID_is_not_nil() throws { + func test_loadMediaLibrary_sends_parent_filter_if_productID_is_not_nil() throws { // Given let remote = MediaRemote(network: network) // When - remote.loadMediaLibrary(for: self.sampleSiteID, + remote.loadMediaLibrary(siteID: self.sampleSiteID, productID: 32, imagesOnly: true, completion: { _ in }) // Then let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) - let postIDValue = try XCTUnwrap(request.parameters?["post_ID"] as? Int64) + let postIDValue = try XCTUnwrap(request.parameters?["parent"] as? Int64) XCTAssertEqual(postIDValue, 32) } - func test_loadMediaLibrary_does_not_send_postID_filter_if_productID_is_nil() throws { + func test_loadMediaLibrary_does_not_send_parent_filter_if_productID_is_nil() throws { // Given let remote = MediaRemote(network: network) // When - remote.loadMediaLibrary(for: self.sampleSiteID, + remote.loadMediaLibrary(siteID: self.sampleSiteID, imagesOnly: true, completion: { _ in }) - // Then - let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) - XCTAssertNil(request.parameters?["post_ID"]) - } - - func test_loadMediaLibraryFromWordPressSite_sends_parent_filter_if_productID_is_not_nil() throws { - // Given - let remote = MediaRemote(network: network) - - // When - remote.loadMediaLibraryFromWordPressSite(siteID: self.sampleSiteID, - productID: 32, - imagesOnly: true, - completion: { _ in }) - - // Then - let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) - let postIDValue = try XCTUnwrap(request.parameters?["parent"] as? Int64) - XCTAssertEqual(postIDValue, 32) - } - - func test_loadMediaLibraryFromWordPressSite_does_not_send_parent_filter_if_productID_is_nil() throws { - // Given - let remote = MediaRemote(network: network) - - // When - remote.loadMediaLibraryFromWordPressSite(siteID: self.sampleSiteID, - imagesOnly: true, - completion: { _ in }) - // Then let request = try XCTUnwrap(network.requestsForResponseData.last as? DotcomRequest) XCTAssertNil(request.parameters?["parent"]) diff --git a/Networking/NetworkingTests/Remote/ProductVariationsRemoteTests.swift b/Networking/NetworkingTests/Remote/ProductVariationsRemoteTests.swift index 2213e2c4ef9..1807ddcf514 100644 --- a/Networking/NetworkingTests/Remote/ProductVariationsRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/ProductVariationsRemoteTests.swift @@ -152,6 +152,106 @@ final class ProductVariationsRemoteTests: XCTestCase { XCTAssertFalse(queryParametersDictionary.contains(where: { $0.key == "include" })) } + // MARK: - Load all POS product variations tests + + func test_loadVariationsForPointOfSale_returns_parsed_variation() async throws { + // Given + let remote = ProductVariationsRemote(network: network) + network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations", filename: "product-variations-load-all") + + // When + let variations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID) + + // Then + XCTAssertEqual(variations.count, 8) + + guard let firstVariation = variations.first else { + XCTFail("The first product variation should exist.") + return + } + XCTAssertEqual(firstVariation.productVariationID, 1275) + XCTAssertEqual(firstVariation.description, "

Nutty chocolate marble, 99% and organic.

\n") + XCTAssertEqual(firstVariation.sku, "99%-nuts-marble") + XCTAssertEqual(firstVariation.globalUniqueID, "12345") + XCTAssertEqual(firstVariation.permalink, "https://chocolate.com/marble") + + XCTAssertEqual(firstVariation.dateCreated, DateFormatter.dateFromString(with: "2019-11-14T12:40:55")) + XCTAssertEqual(firstVariation.dateModified, DateFormatter.dateFromString(with: "2019-11-14T13:06:42")) + XCTAssertEqual(firstVariation.dateOnSaleStart, DateFormatter.dateFromString(with: "2019-10-15T21:30:00")) + XCTAssertEqual(firstVariation.dateOnSaleEnd, DateFormatter.dateFromString(with: "2019-10-27T21:29:59")) + + let expectedPrice = 12 + XCTAssertEqual(firstVariation.price, "\(expectedPrice)") + XCTAssertEqual(firstVariation.regularPrice, "\(expectedPrice)") + XCTAssertEqual(firstVariation.salePrice, "8") + + XCTAssertEqual(firstVariation.status, .published) + XCTAssertEqual(firstVariation.stockStatus, .inStock) + + let expectedAttributes: [ProductVariationAttribute] = [ + ProductVariationAttribute(id: 0, name: "Darkness", option: "99%"), + ProductVariationAttribute(id: 0, name: "Flavor", option: "nuts"), + ProductVariationAttribute(id: 0, name: "Shape", option: "marble") + ] + XCTAssertEqual(firstVariation.attributes, expectedAttributes) + + XCTAssertEqual(firstVariation.image?.imageID, 1063) + + XCTAssertFalse(firstVariation.onSale) + XCTAssertTrue(firstVariation.purchasable) + XCTAssertFalse(firstVariation.virtual) + XCTAssertTrue(firstVariation.downloadable) + + XCTAssertTrue(firstVariation.manageStock) + XCTAssertEqual(firstVariation.stockQuantity, 16.5) + XCTAssertEqual(firstVariation.backordersKey, "notify") + XCTAssertTrue(firstVariation.backordersAllowed) + XCTAssertFalse(firstVariation.backordered) + + XCTAssertEqual(firstVariation.downloads.count, 0) + XCTAssertEqual(firstVariation.downloadLimit, -1) + XCTAssertEqual(firstVariation.downloadExpiry, 0) + + XCTAssertEqual(firstVariation.taxStatusKey, "taxable") + XCTAssertEqual(firstVariation.taxClass, "") + + XCTAssertEqual(firstVariation.weight, "2.5") + XCTAssertEqual(firstVariation.dimensions, ProductDimensions(length: "10", width: "2.5", height: "")) + + XCTAssertEqual(firstVariation.shippingClass, "") + XCTAssertEqual(firstVariation.shippingClassID, 0) + + XCTAssertEqual(firstVariation.menuOrder, 8) + } + + func test_loadVariationsForPointOfSale_properly_relays_networking_error() async throws { + // Given + let remote = ProductVariationsRemote(network: network) + + // When + do { + _ = try await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID) + } catch let error as NetworkError { + // Then + XCTAssertEqual(error, .notFound(response: nil), "Expected a notFound error, but got \(error) instead.") + } catch { + XCTFail("Expected NetworkError.notFound, but got different error: \(error)") + } + } + + + func test_loadVariationsForPointOfSale_does_not_add_include_parameter() async throws { + // Given + let remote = ProductVariationsRemote(network: network) + + // When + _ = try? await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID) + + // Then + let queryParametersDictionary = try XCTUnwrap(network.queryParametersDictionary) + XCTAssertFalse(queryParametersDictionary.contains(where: { $0.key == "include" })) + } + // MARK: - Load single product variation tests /// Verifies that loadProductVariation properly parses the `product-variation` sample response. diff --git a/Networking/NetworkingTests/Remote/ProductsRemoteTests.swift b/Networking/NetworkingTests/Remote/ProductsRemoteTests.swift index f34a77786e2..f6cb4f4eacf 100644 --- a/Networking/NetworkingTests/Remote/ProductsRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/ProductsRemoteTests.swift @@ -932,7 +932,7 @@ final class ProductsRemoteTests: XCTestCase { }) } - func test_loadSimpleProductsForPointOfSale_loads_simple_products() async throws { + func test_loadProductsForPointOfSale_loads_simple_products() async throws { // Given let remote = ProductsRemote(network: network) let expectedProductsFromResponse = 6 @@ -940,28 +940,29 @@ final class ProductsRemoteTests: XCTestCase { // When network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-type-simple") - let products = try await remote.loadSimpleProductsForPointOfSale(for: sampleSiteID) + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID) // Then + let products = pagedProducts.items XCTAssertEqual(products.count, expectedProductsFromResponse) for product in products { XCTAssertEqual(try XCTUnwrap(product).productType, .simple) } } - func test_loadSimpleProductsForPointOfSale_relays_networking_error() async throws { + func test_loadProductsForPointOfSale_relays_networking_error() async throws { // Given let remote = ProductsRemote(network: network) // When/Then await assertThrowsError({ - let _ = try await remote.loadSimpleProductsForPointOfSale(for: sampleSiteID) + let _ = try await remote.loadProductsForPointOfSale(for: sampleSiteID) }, errorAssert: { $0 as? NetworkError == .notFound() }) } - func test_loadSimpleProductsForPointOfSale_when_page_has_products_then_loads_expected_products() async throws { + func test_loadProductsForPointOfSale_when_page_has_products_then_loads_expected_products() async throws { // Given let remote = ProductsRemote(network: network) let initialPageNumber = 1 @@ -970,16 +971,17 @@ final class ProductsRemoteTests: XCTestCase { // When network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-type-simple") - let products = try await remote.loadSimpleProductsForPointOfSale(for: sampleSiteID, pageNumber: initialPageNumber) + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID, pageNumber: initialPageNumber) // Then + let products = pagedProducts.items XCTAssertEqual(products.count, expectedProductsFromResponse) for product in products { XCTAssertEqual(try XCTUnwrap(product).productType, .simple) } } - func test_loadSimpleProductsForPointOfSale_when_page_has_no_products_then_loads_expected_products() async throws { + func test_loadProductsForPointOfSale_when_page_has_no_products_then_loads_expected_products() async throws { // Given let remote = ProductsRemote(network: network) let pageNumber = 2 @@ -988,10 +990,46 @@ final class ProductsRemoteTests: XCTestCase { // When network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") - let products = try await remote.loadSimpleProductsForPointOfSale(for: sampleSiteID, pageNumber: pageNumber) + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID, pageNumber: pageNumber) // Then - XCTAssertEqual(products.count, expectedProductsFromResponse) + XCTAssertEqual(pagedProducts.items.count, expectedProductsFromResponse) + } + + func test_loadProductsForPointOfSale_returns_hasMorePages_based_on_header_with_case_insensitive_name() async throws { + // Given + let remote = ProductsRemote(network: network) + network.responseHeaders = ["X-WP-TotalPages": "5"] + network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") + + // When loading page 1 to 4 + for pageNumber in 1...4 { + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID, pageNumber: pageNumber) + + // Then + XCTAssertTrue(pagedProducts.hasMorePages) + } + + // When loading page 17 + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID, pageNumber: 5) + + // Then + XCTAssertFalse(pagedProducts.hasMorePages) + } + + func test_loadProductsForPointOfSale_returns_hasMorePages_true_when_header_is_not_set() async throws { + // Given + let remote = ProductsRemote(network: network) + network.responseHeaders = nil + network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") + + // When loading the first 5 pages + for pageNumber in 1...5 { + let pagedProducts = try await remote.loadProductsForPointOfSale(for: sampleSiteID, pageNumber: pageNumber) + + // Then + XCTAssertTrue(pagedProducts.hasMorePages) + } } } diff --git a/Networking/NetworkingTests/Remote/SystemStatusRemoteTests.swift b/Networking/NetworkingTests/Remote/SystemStatusRemoteTests.swift index 6a354e8a4d9..7d3e5afd82d 100644 --- a/Networking/NetworkingTests/Remote/SystemStatusRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/SystemStatusRemoteTests.swift @@ -74,7 +74,7 @@ final class SystemStatusRemoteTests: XCTestCase { network.simulateResponse(requestUrlSuffix: "system_status", filename: "systemStatus") // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in remote.fetchSystemStatusReport(for: self.sampleSiteID) { result in promise(result) } @@ -101,7 +101,7 @@ final class SystemStatusRemoteTests: XCTestCase { let remote = SystemStatusRemote(network: network) // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in remote.fetchSystemStatusReport(for: self.sampleSiteID) { result in promise(result) } diff --git a/Networking/NetworkingTests/Remote/WooShippingRemoteTests.swift b/Networking/NetworkingTests/Remote/WooShippingRemoteTests.swift index d60c18425a8..299bb0c8ff2 100644 --- a/Networking/NetworkingTests/Remote/WooShippingRemoteTests.swift +++ b/Networking/NetworkingTests/Remote/WooShippingRemoteTests.swift @@ -38,7 +38,12 @@ final class WooShippingRemoteTests: XCTestCase { // Then let packagesResponse = try XCTUnwrap(result.get()) - XCTAssertEqual(packagesResponse.customPackages.count, 5) + XCTAssertEqual(packagesResponse.customPackages.count, 2) + XCTAssertEqual(packagesResponse.customPackages.first?.id, "69d7052f934a7c218329de9c1abe3858") + XCTAssertEqual(packagesResponse.customPackages.first?.name, "WCS&T Box") + XCTAssertEqual(packagesResponse.customPackages.first?.dimensions, "15 x 15 x 15") + XCTAssertEqual(packagesResponse.customPackages.first?.type, .box) + XCTAssertEqual(packagesResponse.customPackages.first?.boxWeight, 0.25) XCTAssertEqual(packagesResponse.predefinedOptions.count, 1) } @@ -81,6 +86,45 @@ final class WooShippingRemoteTests: XCTestCase { XCTAssertEqual(result.failure as? WooShippingRemote.ShippingError, expectedError) } + func test_deletePackage_parses_success_response() throws { + // Given + let remote = WooShippingRemote(network: network) + let package = WooShippingCustomPackage.fake() + network.simulateResponse(requestUrlSuffix: "packages/\(package.id)", filename: "wooshipping-delete-package-success") + + // When + let result: Result = waitFor { promise in + remote.deletePackage(siteID: self.sampleSiteID, + packageID: package.id) { result in + promise(result) + } + } + + // Then + let packagesResponse = try XCTUnwrap(result.get()) + XCTAssertEqual(packagesResponse.customPackages.count, 5) + XCTAssertEqual(packagesResponse.predefinedOptions.count, 1) + } + + func test_deletePackage_returns_error_on_failure() throws { + // Given + let remote = WooShippingRemote(network: network) + let package = WooShippingCustomPackage.fake() + network.simulateResponse(requestUrlSuffix: "packages/\(package.id)", filename: "generic_error") + + // When + let result: Result = waitFor { promise in + remote.deletePackage(siteID: self.sampleSiteID, + packageID: package.id) { result in + promise(result) + } + } + + // Then + let expectedError = DotcomError.unauthorized + XCTAssertEqual(result.failure as? DotcomError, expectedError) + } + func test_loadLabelRates_parses_success_response() throws { // Given let remote = WooShippingRemote(network: network) @@ -339,6 +383,52 @@ final class WooShippingRemoteTests: XCTestCase { // Then XCTAssertNotNil(result.failure) } + + func test_loadOriginAddresses_parses_success_response() throws { + // Given + let remote = WooShippingRemote(network: network) + network.simulateResponse(requestUrlSuffix: "address/origins", filename: "wooshipping-get-origin-addresses-success") + + // When + let result: Result<[WooShippingOriginAddress], Error> = waitFor { promise in + remote.loadOriginAddresses(siteID: self.sampleSiteID) { result in + promise(result) + } + } + + // Then + let addresses = try XCTUnwrap(result.get()) + XCTAssertEqual(addresses.count, 1) + XCTAssertEqual(addresses.first?.company, "Superlative Centaur") + XCTAssertEqual(addresses.first?.city, "SAN FRANCISCO") + XCTAssertEqual(addresses.first?.state, "CA") + XCTAssertEqual(addresses.first?.postcode, "94110-4929") + XCTAssertEqual(addresses.first?.country, "US") + XCTAssertEqual(addresses.first?.phone, "12345678901") + XCTAssertEqual(addresses.first?.address1, "60 29TH ST PMB 343") + XCTAssertEqual(addresses.first?.firstName, "First") + XCTAssertEqual(addresses.first?.lastName, "Last") + XCTAssertEqual(addresses.first?.email, "email@automattic.com") + XCTAssertEqual(addresses.first?.id, "store_details") + XCTAssertEqual(addresses.first?.defaultAddress, true) + XCTAssertEqual(addresses.first?.isVerified, true) + } + + func test_loadOriginAddresses_returns_error_on_failure() throws { + // Given + let remote = WooShippingRemote(network: network) + network.simulateResponse(requestUrlSuffix: "address/origins", filename: "generic_error") + + // When + let result: Result<[WooShippingOriginAddress], Error> = waitFor { promise in + remote.loadOriginAddresses(siteID: self.sampleSiteID) { result in + promise(result) + } + } + + // Then + XCTAssertNotNil(result.failure) + } } private extension WooShippingRemoteTests { diff --git a/Networking/NetworkingTests/Responses/media-library-from-wordpress-site.json b/Networking/NetworkingTests/Responses/media-library-from-wordpress-site.json deleted file mode 100644 index e2efb669aa4..00000000000 --- a/Networking/NetworkingTests/Responses/media-library-from-wordpress-site.json +++ /dev/null @@ -1,281 +0,0 @@ -[ - { - "id": 28, - "date_gmt": "2023-12-07T05:02:54", - "slug": "xanh-3", - "title": { - "rendered": "Xanh-3" - }, - "alt_text": "", - "mime_type": "text/plain", - "media_details": { - "filesize": 1300, - "sizes": {} - }, - "source_url": "https://ninja.media/wp-content/uploads/2023/12/Xanh-3.txt" - }, - { - "id": 22, - "date_gmt": "2021-11-22T01:55:57", - "slug": "img_0111-2", - "title": { - "rendered": "img_0111-2" - }, - "alt_text": "Floral", - "mime_type": "image/jpeg", - "media_details": { - "width": 2560, - "height": 1920, - "file": "2021/11/img_0111-2-scaled.jpeg", - "sizes": { - "medium": { - "file": "img_0111-2-300x225.jpeg", - "width": 300, - "height": 225, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-300x225.jpeg" - }, - "large": { - "file": "img_0111-2-1024x768.jpeg", - "width": 1024, - "height": 768, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1024x768.jpeg" - }, - "thumbnail": { - "file": "img_0111-2-150x150.jpeg", - "width": 150, - "height": 150, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-150x150.jpeg" - }, - "medium_large": { - "file": "img_0111-2-768x576.jpeg", - "width": 768, - "height": 576, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-768x576.jpeg" - }, - "1536x1536": { - "file": "img_0111-2-1536x1152.jpeg", - "width": 1536, - "height": 1152, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1536x1152.jpeg" - }, - "2048x2048": { - "file": "img_0111-2-2048x1536.jpeg", - "width": 2048, - "height": 1536, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-2048x1536.jpeg" - }, - "post-thumbnail": { - "file": "img_0111-2-1568x1176.jpeg", - "width": 1568, - "height": 1176, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1568x1176.jpeg" - }, - "woocommerce_thumbnail": { - "file": "img_0111-2-450x450.jpeg", - "width": 450, - "height": 450, - "uncropped": false, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-450x450.jpeg" - }, - "woocommerce_single": { - "file": "img_0111-2-600x450.jpeg", - "width": 600, - "height": 450, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-600x450.jpeg" - }, - "woocommerce_gallery_thumbnail": { - "file": "img_0111-2-100x100.jpeg", - "width": 100, - "height": 100, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-100x100.jpeg" - }, - "shop_catalog": { - "file": "img_0111-2-450x450.jpeg", - "width": 450, - "height": 450, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-450x450.jpeg" - }, - "shop_single": { - "file": "img_0111-2-600x450.jpeg", - "width": 600, - "height": 450, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-600x450.jpeg" - }, - "shop_thumbnail": { - "file": "img_0111-2-100x100.jpeg", - "width": 100, - "height": 100, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-100x100.jpeg" - }, - "full": { - "file": "img_0111-2-scaled.jpeg", - "width": 2560, - "height": 1920, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-scaled.jpeg" - } - }, - "image_meta": { - "aperture": "2.4", - "credit": "", - "camera": "iPhone X", - "caption": "", - "created_timestamp": "1522412059", - "copyright": "", - "focal_length": "6", - "iso": "16", - "shutter_speed": "0.0047846889952153", - "title": "", - "orientation": "1", - "keywords": [] - }, - "original_image": "img_0111-2.jpeg" - }, - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-scaled.jpeg" - }, - { - "id": 20, - "date_gmt": "2021-11-21T14:14:56", - "slug": "godafoss", - "title": { - "rendered": "godafoss" - }, - "alt_text": "", - "mime_type": "image/jpeg", - "media_details": { - "width": 2560, - "height": 1708, - "file": "2021/11/img_0003-scaled.jpeg", - "sizes": { - "medium": { - "file": "img_0003-300x200.jpeg", - "width": 300, - "height": 200, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-300x200.jpeg" - }, - "large": { - "file": "img_0003-1024x683.jpeg", - "width": 1024, - "height": 683, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-1024x683.jpeg" - }, - "thumbnail": { - "file": "img_0003-150x150.jpeg", - "width": 150, - "height": 150, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-150x150.jpeg" - }, - "medium_large": { - "file": "img_0003-768x513.jpeg", - "width": 768, - "height": 513, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-768x513.jpeg" - }, - "1536x1536": { - "file": "img_0003-1536x1025.jpeg", - "width": 1536, - "height": 1025, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-1536x1025.jpeg" - }, - "2048x2048": { - "file": "img_0003-2048x1367.jpeg", - "width": 2048, - "height": 1367, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-2048x1367.jpeg" - }, - "post-thumbnail": { - "file": "img_0003-1568x1046.jpeg", - "width": 1568, - "height": 1046, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-1568x1046.jpeg" - }, - "woocommerce_thumbnail": { - "file": "img_0003-450x450.jpeg", - "width": 450, - "height": 450, - "uncropped": false, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-450x450.jpeg" - }, - "woocommerce_single": { - "file": "img_0003-600x400.jpeg", - "width": 600, - "height": 400, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-600x400.jpeg" - }, - "woocommerce_gallery_thumbnail": { - "file": "img_0003-100x100.jpeg", - "width": 100, - "height": 100, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-100x100.jpeg" - }, - "shop_catalog": { - "file": "img_0003-450x450.jpeg", - "width": 450, - "height": 450, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-450x450.jpeg" - }, - "shop_single": { - "file": "img_0003-600x400.jpeg", - "width": 600, - "height": 400, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-600x400.jpeg" - }, - "shop_thumbnail": { - "file": "img_0003-100x100.jpeg", - "width": 100, - "height": 100, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-100x100.jpeg" - }, - "full": { - "file": "img_0003-scaled.jpeg", - "width": 2560, - "height": 1708, - "mime_type": "image/jpeg", - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-scaled.jpeg" - } - }, - "image_meta": { - "aperture": "10", - "credit": "Nicolas Cornet", - "camera": "NIKON D800E", - "caption": "", - "created_timestamp": "1344426731", - "copyright": "Nicolas Cornet", - "focal_length": "24", - "iso": "200", - "shutter_speed": "4", - "title": "Godafoss", - "orientation": "1", - "keywords": [] - }, - "original_image": "img_0003.jpeg" - }, - "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0003-scaled.jpeg" - } -] diff --git a/Networking/NetworkingTests/Responses/media-library.json b/Networking/NetworkingTests/Responses/media-library.json index 381b4211107..ee244754981 100644 --- a/Networking/NetworkingTests/Responses/media-library.json +++ b/Networking/NetworkingTests/Responses/media-library.json @@ -1,128 +1,281 @@ -{ - "media": [ - { - "ID": 2352, - "URL": "https://test.com/wp-content/uploads/2020/02/img_0002-8.jpeg", - "date": "2020-02-21T12:15:38+08:00", - "mime_type": "image/jpeg", - "file": "img_0002-8.jpeg", - "extension": "jpeg", - "title": "DSC_0010", - "alt": "", - "thumbnails": { - "medium": "https://test.com/wp-content/uploads/2020/02/img_0002-8-300x199.jpeg", - "large": "https://test.com/wp-content/uploads/2020/02/img_0002-8-1024x680.jpeg", - "thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0002-8-150x150.jpeg", - "medium_large": "https://test.com/wp-content/uploads/2020/02/img_0002-8-768x510.jpeg", - "1536x1536": "https://test.com/wp-content/uploads/2020/02/img_0002-8-1536x1020.jpeg", - "2048x2048": "https://test.com/wp-content/uploads/2020/02/img_0002-8-2048x1361.jpeg", - "woocommerce_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0002-8-350x350.jpeg", - "woocommerce_single": "https://test.com/wp-content/uploads/2020/02/img_0002-8-685x455.jpeg", - "woocommerce_gallery_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0002-8-100x100.jpeg", - "shop_catalog": "https://test.com/wp-content/uploads/2020/02/img_0002-8-350x350.jpeg", - "shop_single": "https://test.com/wp-content/uploads/2020/02/img_0002-8-685x455.jpeg", - "shop_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0002-8-100x100.jpeg" - } +[ + { + "id": 22, + "date_gmt": "2021-11-22T01:55:57", + "slug": "img_0111-2", + "title": { + "rendered": "img_0111-2" }, - { - "ID": 2351, - "URL": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13.jpeg", - "date": "2020-02-21T12:14:44+08:00", - "mime_type": "image/jpeg", - "file": "img_0111-1-13.jpeg", - "extension": "jpeg", - "title": "img_0111-1", - "alt": "", - "thumbnails": { - "medium": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-300x225.jpeg", - "large": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-1024x768.jpeg", - "thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-150x150.jpeg", - "medium_large": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-768x576.jpeg", - "1536x1536": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-1536x1152.jpeg", - "2048x2048": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-2048x1536.jpeg", - "woocommerce_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-350x350.jpeg", - "woocommerce_single": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-685x514.jpeg", - "woocommerce_gallery_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-100x100.jpeg", - "shop_catalog": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-350x350.jpeg", - "shop_single": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-685x514.jpeg", - "shop_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-13-100x100.jpeg" - } + "alt_text": "Floral", + "mime_type": "image/jpeg", + "media_details": { + "width": 2560, + "height": 1920, + "file": "2021/11/img_0111-2-scaled.jpeg", + "sizes": { + "medium": { + "file": "img_0111-2-300x225.jpeg", + "width": 300, + "height": 225, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-300x225.jpeg" + }, + "large": { + "file": "img_0111-2-1024x768.jpeg", + "width": 1024, + "height": 768, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1024x768.jpeg" + }, + "thumbnail": { + "file": "img_0111-2-150x150.jpeg", + "width": 150, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-150x150.jpeg" + }, + "medium_large": { + "file": "img_0111-2-768x576.jpeg", + "width": 768, + "height": 576, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-768x576.jpeg" + }, + "1536x1536": { + "file": "img_0111-2-1536x1152.jpeg", + "width": 1536, + "height": 1152, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1536x1152.jpeg" + }, + "2048x2048": { + "file": "img_0111-2-2048x1536.jpeg", + "width": 2048, + "height": 1536, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-2048x1536.jpeg" + }, + "post-thumbnail": { + "file": "img_0111-2-1568x1176.jpeg", + "width": 1568, + "height": 1176, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-1568x1176.jpeg" + }, + "woocommerce_thumbnail": { + "file": "img_0111-2-450x450.jpeg", + "width": 450, + "height": 450, + "uncropped": false, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-450x450.jpeg" + }, + "woocommerce_single": { + "file": "img_0111-2-600x450.jpeg", + "width": 600, + "height": 450, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-600x450.jpeg" + }, + "woocommerce_gallery_thumbnail": { + "file": "img_0111-2-100x100.jpeg", + "width": 100, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-100x100.jpeg" + }, + "shop_catalog": { + "file": "img_0111-2-450x450.jpeg", + "width": 450, + "height": 450, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-450x450.jpeg" + }, + "shop_single": { + "file": "img_0111-2-600x450.jpeg", + "width": 600, + "height": 450, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-600x450.jpeg" + }, + "shop_thumbnail": { + "file": "img_0111-2-100x100.jpeg", + "width": 100, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-100x100.jpeg" + }, + "full": { + "file": "img_0111-2-scaled.jpeg", + "width": 2560, + "height": 1920, + "mime_type": "image/jpeg", + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-scaled.jpeg" + } + }, + "image_meta": { + "aperture": "2.4", + "credit": "", + "camera": "iPhone X", + "caption": "", + "created_timestamp": "1522412059", + "copyright": "", + "focal_length": "6", + "iso": "16", + "shutter_speed": "0.0047846889952153", + "title": "", + "orientation": "1", + "keywords": [] + }, + "original_image": "img_0111-2.jpeg" }, - { - "ID": 2350, - "URL": "https://test.com/wp-content/uploads/2020/02/img_0005-15.jpeg", - "date": "2020-02-21T12:00:50+08:00", - "mime_type": "image/jpeg", - "file": "img_0005-15.jpeg", - "extension": "jpeg", - "title": "img_0005", - "alt": "", - "thumbnails": { - "medium": "https://test.com/wp-content/uploads/2020/02/img_0005-15-300x200.jpeg", - "large": "https://test.com/wp-content/uploads/2020/02/img_0005-15-1024x683.jpeg", - "thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-15-150x150.jpeg", - "medium_large": "https://test.com/wp-content/uploads/2020/02/img_0005-15-768x513.jpeg", - "1536x1536": "https://test.com/wp-content/uploads/2020/02/img_0005-15-1536x1025.jpeg", - "2048x2048": "https://test.com/wp-content/uploads/2020/02/img_0005-15-2048x1367.jpeg", - "woocommerce_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-15-350x350.jpeg", - "woocommerce_single": "https://test.com/wp-content/uploads/2020/02/img_0005-15-685x457.jpeg", - "woocommerce_gallery_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-15-100x100.jpeg", - "shop_catalog": "https://test.com/wp-content/uploads/2020/02/img_0005-15-350x350.jpeg", - "shop_single": "https://test.com/wp-content/uploads/2020/02/img_0005-15-685x457.jpeg", - "shop_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-15-100x100.jpeg" - } + "source_url": "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-scaled.jpeg" + }, + { + "id": 20, + "date_gmt": "2021-11-22T01:55:57", + "slug": "img_0111-1", + "title": { + "rendered": "img_0111-1" }, - { - "ID": 2349, - "URL": "https://test.com/wp-content/uploads/2020/02/img_0005-14.jpeg", - "date": "2020-02-21T11:59:53+08:00", - "mime_type": "image/jpeg", - "file": "img_0005-14.jpeg", - "extension": "jpeg", - "title": "img_0005", - "alt": "", - "thumbnails": { - "medium": "https://test.com/wp-content/uploads/2020/02/img_0005-14-300x200.jpeg", - "large": "https://test.com/wp-content/uploads/2020/02/img_0005-14-1024x683.jpeg", - "thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-14-150x150.jpeg", - "medium_large": "https://test.com/wp-content/uploads/2020/02/img_0005-14-768x513.jpeg", - "1536x1536": "https://test.com/wp-content/uploads/2020/02/img_0005-14-1536x1025.jpeg", - "2048x2048": "https://test.com/wp-content/uploads/2020/02/img_0005-14-2048x1367.jpeg", - "woocommerce_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-14-350x350.jpeg", - "woocommerce_single": "https://test.com/wp-content/uploads/2020/02/img_0005-14-685x457.jpeg", - "woocommerce_gallery_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-14-100x100.jpeg", - "shop_catalog": "https://test.com/wp-content/uploads/2020/02/img_0005-14-350x350.jpeg", - "shop_single": "https://test.com/wp-content/uploads/2020/02/img_0005-14-685x457.jpeg", - "shop_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0005-14-100x100.jpeg" - } - }, - { - "ID": 2348, - "URL": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест.jpeg", - "date": "2020-02-21T11:58:24+08:00", - "mime_type": "image/jpeg", - "extension": "jpeg", + "alt_text": "", + "mime_type": "image/jpeg", + "media_details": { + "width": 2560, + "height": 1708, "file": "img_0111-1-12-тест.jpeg", - "title": "img_0111-1", - "alt": "", - "thumbnails": { - "medium": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-300x225.jpeg", - "large": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-1024x768.jpeg", - "thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-150x150.jpeg", - "medium_large": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-768x576.jpeg", - "1536x1536": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-1536x1152.jpeg", - "2048x2048": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-2048x1536.jpeg", - "woocommerce_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-350x350.jpeg", - "woocommerce_single": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-685x514.jpeg", - "woocommerce_gallery_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-100x100.jpeg", - "shop_catalog": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-350x350.jpeg", - "shop_single": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-685x514.jpeg", - "shop_thumbnail": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-100x100.jpeg" - } - } - ], - "found": 185, - "meta": { - "next_page": "value=2020-02-21T11%3A58%3A24%2B08%3A00&id=2348" + "sizes": { + "medium": { + "file": "img_0111-1-12-тест-300x200.jpeg", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-300x200.jpeg" + }, + "large": { + "file": "img_0111-1-12-тест-1024x683.jpeg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-1024x683.jpeg" + }, + "thumbnail": { + "file": "img_0111-1-12-тест-150x150.jpeg", + "width": 150, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-150x150.jpeg" + }, + "medium_large": { + "file": "img_0111-1-12-тест-768x513.jpeg", + "width": 768, + "height": 513, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-768x513.jpeg" + }, + "1536x1536": { + "file": "img_0111-1-12-тест-1536x1025.jpeg", + "width": 1536, + "height": 1025, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-1536x1025.jpeg" + }, + "2048x2048": { + "file": "img_0111-1-12-тест-2048x1367.jpeg", + "width": 2048, + "height": 1367, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-2048x1367.jpeg" + }, + "post-thumbnail": { + "file": "img_0111-1-12-тест-1568x1046.jpeg", + "width": 1568, + "height": 1046, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-1568x1046.jpeg" + }, + "woocommerce_thumbnail": { + "file": "img_0111-1-12-тест-450x450.jpeg", + "width": 450, + "height": 450, + "uncropped": false, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-450x450.jpeg" + }, + "woocommerce_single": { + "file": "img_0111-1-12-тест-600x400.jpeg", + "width": 600, + "height": 400, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-600x400.jpeg" + }, + "woocommerce_gallery_thumbnail": { + "file": "img_0111-1-12-тест-100x100.jpeg", + "width": 100, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-100x100.jpeg" + }, + "shop_catalog": { + "file": "img_0111-1-12-тест-450x450.jpeg", + "width": 450, + "height": 450, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-450x450.jpeg" + }, + "shop_single": { + "file": "img_0111-1-12-тест-600x400.jpeg", + "width": 600, + "height": 400, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-600x400.jpeg" + }, + "shop_thumbnail": { + "file": "img_0111-1-12-тест-100x100.jpeg", + "width": 100, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-100x100.jpeg" + }, + "full": { + "file": "img_0111-1-12-тест.jpeg", + "width": 2560, + "height": 1708, + "mime_type": "image/jpeg", + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест.jpeg" + } + }, + "image_meta": { + "aperture": "10", + "credit": "Nicolas Cornet", + "camera": "NIKON D800E", + "caption": "", + "created_timestamp": "1344426731", + "copyright": "Nicolas Cornet", + "focal_length": "24", + "iso": "200", + "shutter_speed": "4", + "title": "img_0111-1", + "orientation": "1", + "keywords": [] + }, + "original_image": "img_0111-1-12-тест.jpeg" + }, + "source_url": "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест.jpeg" + }, + { + "id": 28, + "date_gmt": "2023-12-07T05:02:54", + "slug": "xanh-3", + "title": { + "rendered": "Xanh-3" + }, + "alt_text": "", + "mime_type": "text/plain", + "media_details": { + "filesize": 1300, + "sizes": {} + }, + "source_url": "https://ninja.media/wp-content/uploads/2023/12/Xanh-3.txt" } -} +] diff --git a/Networking/NetworkingTests/Responses/notification-settings.json b/Networking/NetworkingTests/Responses/notification-settings.json new file mode 100644 index 00000000000..5685aeee88d --- /dev/null +++ b/Networking/NetworkingTests/Responses/notification-settings.json @@ -0,0 +1,114 @@ +{ + "blogs": [ + { + "blog_id": 190864441, + "timeline": { + "new_comment": true, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + }, + "email": { + "new_comment": true, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + }, + "devices": [ + { + "device_id": 58089781, + "new_comment": true, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + } + ] + }, + { + "blog_id": 194373765, + "timeline": { + "new_comment": true, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + }, + "email": { + "new_comment": true, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + }, + "devices": [ + { + "device_id": 58089781, + "new_comment": false, + "comment_like": true, + "post_like": true, + "follow": true, + "achievement": true, + "mentions": true, + "scheduled_publicize": true, + "store_order": true, + "blogging_prompt": false, + "draft_post_prompt": true + } + ] + }, + ] + }, + "wpcom": { + "marketing": false, + "research": true, + "affiliates": true, + "community": true, + "promotion": false, + "news": true, + "digest": true, + "reports": true, + "news_developer": true, + "wpcom_spain": false, + "scheduled_updates": true, + "learn": false, + "a4a_agencies": true, + "jetpack_agencies": true, + "jetpack_manage_onboarding": true, + "jetpack_marketing": false, + "jetpack_research": true, + "jetpack_promotion": false, + "jetpack_news": true, + "jetpack_reports": true, + "akismet_marketing": false, + "woopay_marketing": true, + "gravatar_onboarding": true + } +} diff --git a/Networking/NetworkingTests/Responses/systemStatus-inconsistent-environment-max-upload-size-data-type.json b/Networking/NetworkingTests/Responses/systemStatus-inconsistent-environment-max-upload-size-data-type.json new file mode 100644 index 00000000000..f1c01048e04 --- /dev/null +++ b/Networking/NetworkingTests/Responses/systemStatus-inconsistent-environment-max-upload-size-data-type.json @@ -0,0 +1,101 @@ +{ + "environment": { + "home_url": "https://additional-beetle.jurassic.ninja", + "site_url": "https://additional-beetle.jurassic.ninja", + "store_id": "sample-store-uuid", + "version": "5.9.0", + "log_directory": "/srv/users/user9fe179e8/apps/user9fe179e8/public/wp-content/uploads/wc-logs/", + "log_directory_writable": true, + "wp_version": "5.8.2", + "wp_multisite": false, + "wp_memory_limit": 268435456, + "wp_debug_mode": true, + "wp_cron": true, + "language": "en_US", + "external_object_cache": null, + "server_info": "Apache/2.4.51 (Unix) OpenSSL/1.0.2g", + "php_version": "7.4.26", + "php_post_max_size": 1073741824, + "php_max_execution_time": 30, + "php_max_input_vars": 5000, + "curl_version": "7.47.0, OpenSSL/1.0.2g", + "suhosin_installed": false, + "max_upload_size": "536870912", + "mysql_version": "5.7.33", + "mysql_version_string": "5.7.33-0ubuntu0.16.04.1-log", + "default_timezone": "UTC", + "fsockopen_or_curl_enabled": true, + "soapclient_enabled": true, + "domdocument_enabled": true, + "gzip_enabled": true, + "mbstring_enabled": true, + "remote_post_successful": true, + "remote_post_response": 200, + "remote_get_successful": true, + "remote_get_response": 200 + }, + "active_plugins":[ + { + "plugin":"woocommerce\/woocommerce.php", + "name":"WooCommerce", + "version":"5.8.0", + "version_latest":"5.8.0", + "url":"https:\/\/woocommerce.com\/", + "author_name":"Automattic", + "author_url":"https:\/\/woocommerce.com", + "network_activated":false + }, + { + "plugin":"woocommerce-payments\/woocommerce-payments.php", + "name":"WooCommerce Payments", + "version":"3.1.0", + "version_latest":"3.1.0", + "url":"https:\/\/woocommerce.com\/payments\/", + "author_name":"Automattic", + "author_url":"https:\/\/woocommerce.com\/", + "network_activated":false + }, + { + "plugin":"woocommerce-subscriptions\/woocommerce-subscriptions", + "name":"WooCommerce Subscriptions", + "version":"3.1.6", + "version_latest":"3.1.6", + "url":"https:\/\/www.woocommerce.com\/products\/woocommerce-subscriptions\/", + "author_name":"Automattic", + "author_url":"https:\/\/woocommerce.com\/", + "network_activated":false + }, + { + "plugin":"jetpack\/jetpack.php", + "name":"Jetpack", + "version":"10.2", + "version_latest":"10.2.1", + "url":"https:\/\/jetpack.com", + "author_name":"Automattic", + "author_url":"https:\/\/jetpack.com", + "network_activated":false + } + ], + "inactive_plugins":[ + { + "plugin":"akismet\/akismet.php", + "name":"Akismet Anti-Spam", + "version":"4.2.1", + "version_latest":"4.2.1", + "url":"https:\/\/akismet.com\/", + "author_name":"Automattic", + "author_url":"https:\/\/automattic.com\/wordpress-plugins\/", + "network_activated":false + }, + { + "plugin":"hello.php", + "name":"Hello Dolly", + "version":"1.7.2", + "version_latest":"1.7.2", + "url":"http:\/\/wordpress.org\/plugins\/hello-dolly\/", + "author_name":"Matt Mullenweg", + "author_url":"http:\/\/ma.tt\/", + "network_activated":false + } + ] +} diff --git a/Networking/NetworkingTests/Responses/systemStatus-inconsistent-page-id-data-type.json b/Networking/NetworkingTests/Responses/systemStatus-inconsistent-page-id-data-type.json index 7f63163c978..a67cec336a6 100644 --- a/Networking/NetworkingTests/Responses/systemStatus-inconsistent-page-id-data-type.json +++ b/Networking/NetworkingTests/Responses/systemStatus-inconsistent-page-id-data-type.json @@ -2,6 +2,7 @@ "environment": { "home_url": "https://additional-beetle.jurassic.ninja", "site_url": "https://additional-beetle.jurassic.ninja", + "store_id": "sample-store-uuid", "version": "5.9.0", "log_directory": "/srv/users/user9fe179e8/apps/user9fe179e8/public/wp-content/uploads/wc-logs/", "log_directory_writable": true, diff --git a/Networking/NetworkingTests/Responses/wooshipping-create-package-success.json b/Networking/NetworkingTests/Responses/wooshipping-create-package-success.json index fa445c13f23..ebe726c1eca 100644 --- a/Networking/NetworkingTests/Responses/wooshipping-create-package-success.json +++ b/Networking/NetworkingTests/Responses/wooshipping-create-package-success.json @@ -12,7 +12,7 @@ "name": "WCS&T Box", "dimensions": "15 x 15 x 15", "boxWeight": 0.25, - "maxWeight": 0, + "max_weight": 0, "type": "box", "is_user_defined": true }, @@ -20,35 +20,8 @@ "id": "517116d2f2d929115f6e081ba780b84e", "name": "WCS&T Envelope", "dimensions": "30 x 10 x 1", - "boxWeight": 0.1, - "maxWeight": 0, - "type": "envelope", - "is_user_defined": true - }, - { - "id": "a344e7fa99e8bb88b15feb13141bf62c", - "name": "WCShip Envelope", - "dimensions": "100 x 10 x 1", - "boxWeight": 0.25, - "maxWeight": 0, - "type": "envelope", - "is_user_defined": true - }, - { - "id": "22025742fbfaf4d7e73da11caaff9a2a", - "name": "WCShip Box", - "dimensions": "10 x 10 x 10", - "boxWeight": 1, - "maxWeight": 0, - "type": "box", - "is_user_defined": true - }, - { - "id": "dd58dcd5dc044b6c56540c557c231c4f", - "name": "WCShip Envelope 2", - "dimensions": "10 x 10 x 1", - "boxWeight": 1, - "maxWeight": 0, + "box_weight": 0.25, + "max_weight": 0, "type": "envelope", "is_user_defined": true } diff --git a/Networking/NetworkingTests/Responses/wooshipping-delete-package-success.json b/Networking/NetworkingTests/Responses/wooshipping-delete-package-success.json new file mode 100644 index 00000000000..fa445c13f23 --- /dev/null +++ b/Networking/NetworkingTests/Responses/wooshipping-delete-package-success.json @@ -0,0 +1,57 @@ +{ + "data": { + "predefined": { + "usps": [ + "small_flat_box", + "flat_envelope" + ] + }, + "custom": [ + { + "id": "69d7052f934a7c218329de9c1abe3858", + "name": "WCS&T Box", + "dimensions": "15 x 15 x 15", + "boxWeight": 0.25, + "maxWeight": 0, + "type": "box", + "is_user_defined": true + }, + { + "id": "517116d2f2d929115f6e081ba780b84e", + "name": "WCS&T Envelope", + "dimensions": "30 x 10 x 1", + "boxWeight": 0.1, + "maxWeight": 0, + "type": "envelope", + "is_user_defined": true + }, + { + "id": "a344e7fa99e8bb88b15feb13141bf62c", + "name": "WCShip Envelope", + "dimensions": "100 x 10 x 1", + "boxWeight": 0.25, + "maxWeight": 0, + "type": "envelope", + "is_user_defined": true + }, + { + "id": "22025742fbfaf4d7e73da11caaff9a2a", + "name": "WCShip Box", + "dimensions": "10 x 10 x 10", + "boxWeight": 1, + "maxWeight": 0, + "type": "box", + "is_user_defined": true + }, + { + "id": "dd58dcd5dc044b6c56540c557c231c4f", + "name": "WCShip Envelope 2", + "dimensions": "10 x 10 x 1", + "boxWeight": 1, + "maxWeight": 0, + "type": "envelope", + "is_user_defined": true + } + ] + } +} diff --git a/Networking/NetworkingTests/Responses/wooshipping-get-origin-addresses-success.json b/Networking/NetworkingTests/Responses/wooshipping-get-origin-addresses-success.json new file mode 100644 index 00000000000..cc3f1f24f69 --- /dev/null +++ b/Networking/NetworkingTests/Responses/wooshipping-get-origin-addresses-success.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "company": "Superlative Centaur", + "address_2": "", + "city": "SAN FRANCISCO", + "state": "CA", + "postcode": "94110-4929", + "country": "US", + "phone": "12345678901", + "address_1": "60 29TH ST PMB 343", + "first_name": "First", + "last_name": "Last", + "email": "email@automattic.com", + "id": "store_details", + "default_address": true, + "is_verified": true + } + ] +} diff --git a/Networking/NetworkingTests/Responses/wooshipping-get-packages-success.json b/Networking/NetworkingTests/Responses/wooshipping-get-packages-success.json index 487054c2607..009734b42ac 100644 --- a/Networking/NetworkingTests/Responses/wooshipping-get-packages-success.json +++ b/Networking/NetworkingTests/Responses/wooshipping-get-packages-success.json @@ -11,10 +11,10 @@ "custom": [ { "name": "Custom name", - "boxWeight": "0.01", + "box_weight": "0.01", "id": "849225dc153", "type": "box", - "isLetter": false, + "is_letter": false, "dimensions": "12 x 12 x 12", "is_user_defined": true } @@ -34,7 +34,7 @@ { "inner_dimensions": "8.63 x 5.38 x 1.63", "outer_dimensions": "8.63 x 5.38 x 1.63", - "box_weight": 0, + "boxWeight": 0, "is_flat_rate": true, "id": "small_flat_box", "name": "Small Flat Rate Box", @@ -47,7 +47,7 @@ { "inner_dimensions": "11 x 8.5 x 5.5", "outer_dimensions": "11.25 x 8.75 x 6", - "box_weight": 0, + "boxWeight": 0, "is_flat_rate": true, "id": "medium_flat_box_top", "name": "Medium Flat Rate Box 1, Top Loading", diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index e77abf987d6..402e9f8d217 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,5 +1,11 @@ *** PLEASE FOLLOW THIS FORMAT: [] [] *** Use [*****] to indicate smoke tests of all critical flows should be run on the final IPA before release (e.g. major library or OS update). +21.4 +----- +- [**] Store Picker: Sites can now be hidden from the list for a more focused experience for agencies. [https://github.com/woocommerce/woocommerce-ios/pull/14780] +- [*] Fetching plugins info in the app (e.g. payments onboarding) is more resilient to unexpected types in unrelated fields in the system status response. This used to result in a decoding error. [https://github.com/woocommerce/woocommerce-ios/pull/14781] +- [*] Fixed an issue on iPad where creating an order with a custom amount type failed after selecting and deselecting a product. [https://github.com/woocommerce/woocommerce-ios/pull/14789] + 21.3 ----- - [*] Jetpack Setup: Fixed an issue where the WordPress.com authentication fails when using a passwordless account that's already connected to Jetpack [https://github.com/woocommerce/woocommerce-ios/pull/14501] @@ -7,18 +13,42 @@ - [*] Product Tags: Improved the tag list screen by displaying the cached items on first load and fixed issue clearing all tags [https://github.com/woocommerce/woocommerce-ios/pull/14511] - [**] Media Library: On sites logged in with application password, when picking image from WordPress Media Library, all images will now load correctly. [https://github.com/woocommerce/woocommerce-ios/pull/14444] - [*] Payments onboarding: the custom background color of the "Choose your Payment Provider" UI when both WooPayments and Stripe Extension are available was removed to enable use cases with different container background colors. [https://github.com/woocommerce/woocommerce-ios/pull/14546] -- [*] Media: Media upload will work for sites without XML-RPC. [https://github.com/woocommerce/woocommerce-ios/pull/14537] +- [*] Reviews: Fix issue removing highlight from read reviews [https://github.com/woocommerce/woocommerce-ios/pull/14675] +- [**] Products: Media upload will work for sites without XML-RPC. [https://github.com/woocommerce/woocommerce-ios/pull/14537] +- [**] Products: Media library can now be loaded for sites without XML-RPC [https://github.com/woocommerce/woocommerce-ios/pull/14595] +- [**] Products: Saving product images now works for sites without XML-RPC [https://github.com/woocommerce/woocommerce-ios/pull/14595] - [Internal] Updated CoreDataManager to be thread-safe [https://github.com/woocommerce/woocommerce-ios/pull/14534] - [Internal] Core Data: Migrate storage usage in SiteStore [https://github.com/woocommerce/woocommerce-ios/pull/14548] - [Internal] Updated storage usage in CouponStore [https://github.com/woocommerce/woocommerce-ios/pull/14530] +- [Internal] Core Data: Optimize storage usage in CustomerStore [https://github.com/woocommerce/woocommerce-ios/pull/14552] - [Internal] Update storage usage for BlazeStore [https://github.com/woocommerce/woocommerce-ios/pull/14532] - [Internal] Updated storage usage in ProductShippingClassStore [https://github.com/woocommerce/woocommerce-ios/pull/14520] +- [Internal] Updated storage usage in NotificationStore [https://github.com/woocommerce/woocommerce-ios/pull/14577] +- [Internal] Updated storage usage in OrderNoteStore [https://github.com/woocommerce/woocommerce-ios/pull/14572] +- [Internal] Updated storage usage in AddOnGroupStore [https://github.com/woocommerce/woocommerce-ios/pull/14576] +- [Internal] Updated storage usage in InboxNoteStore [https://github.com/woocommerce/woocommerce-ios/pull/14574] +- [Internal] Updated storage usage in DataStore [https://github.com/woocommerce/woocommerce-ios/pull/14575] +- [Internal] Updated storage usage in PaymentGatewayStore [https://github.com/woocommerce/woocommerce-ios/pull/14570] +- [Internal] updated storage usage in SettingStore [https://github.com/woocommerce/woocommerce-ios/pull/14569] - [Internal] Updated storage usage in ShippingMethodStore [https://github.com/woocommerce/woocommerce-ios/pull/14568] - [Internal] Updated storage usage in TaxStore [https://github.com/woocommerce/woocommerce-ios/pull/14567] - [Internal] Updated storage usage in SystemStatusStore [https://github.com/woocommerce/woocommerce-ios/pull/14559] - [Internal] Updated storage usage in SitePluginStore [https://github.com/woocommerce/woocommerce-ios/pull/14560] - [Internal] Updated storage usage in ShippingLabelStore [https://github.com/woocommerce/woocommerce-ios/pull/14566] +- [Internal] Updated storage usage in MetaDataStore [https://github.com/woocommerce/woocommerce-ios/pull/14642] +- [Internal] Prevent potential crashes for converting non-optional dates of deleted objects [https://github.com/woocommerce/woocommerce-ios/pull/14664] - [*] Fixed: Improved the error message displayed when Bluetooth permission is denied during the card reader connection process. [https://github.com/woocommerce/woocommerce-ios/pull/14561] +- [*] Payments: error details are now displayed for more Stripe errors, instead of a generic error message. [https://github.com/woocommerce/woocommerce-ios/pull/14583] +- [Internal] Updated transformable attribute types from Swift Array to NSArray [https://github.com/woocommerce/woocommerce-ios/pull/14611] +- [Internal] Updated the storage layer to enforce write operations in the background. [https://github.com/woocommerce/woocommerce-ios/pull/14644] +- [*] Order Creation: fixed layout of custom amount type selection drawer. [https://github.com/woocommerce/woocommerce-ios/pull/14619] +- [*] Payments: Improved the Tap to Pay connection process by introducing a location permission pre-alert and earlier location request, ensuring greater clarity for users. [https://github.com/woocommerce/woocommerce-ios/pull/14660] +- [*] Widgets: Show revenue compact amount in widgets, instead of $123,456,789 (and being truncated) we show $123,5m. [https://github.com/woocommerce/woocommerce-ios/pull/14634] +- [*] Google Ads: Fix issue displaying campaign list for stores with existing campaigns from the hub menu entry point [https://github.com/woocommerce/woocommerce-ios/pull/14661] +- [*] Payments: Improved the card reader connection process by introducing a location permission pre-alert and earlier location request, ensuring greater clarity for users. [https://github.com/woocommerce/woocommerce-ios/pull/14672] +- [*] Payments: Tap to Pay is now the first payment method for eligible merchants. [https://github.com/woocommerce/woocommerce-ios/pull/14717] +- [**] Payments: Tap to Pay onboarding now includes detailed guidelines for accepting payments with Tap to Pay on iPhone and educating customers on using contactless payments. [https://github.com/woocommerce/woocommerce-ios/pull/14731] +- [**] Receipts: Email receipts can now be sent to customers after both successful and failed payments. This feature is available for merchants using WooCommerce version 9.5+ and WooPayments 8.6+. [https://github.com/woocommerce/woocommerce-ios/pull/14731]. 21.2 ----- diff --git a/Storage/Storage.xcodeproj/project.pbxproj b/Storage/Storage.xcodeproj/project.pbxproj index 76625226221..69cb9b3b10b 100644 --- a/Storage/Storage.xcodeproj/project.pbxproj +++ b/Storage/Storage.xcodeproj/project.pbxproj @@ -194,6 +194,18 @@ CC80E408294B33F100D5FF45 /* SiteSummaryStats+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC80E407294B33F100D5FF45 /* SiteSummaryStats+CoreDataProperties.swift */; }; CCBEBD4027C68E660010C96F /* FeatureIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCBEBD3F27C68E660010C96F /* FeatureIcon.swift */; }; CCD2E70725DE9AAA00BD975D /* WooCommerceModelV45toV46.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = CCD2E70625DE9AAA00BD975D /* WooCommerceModelV45toV46.xcmappingmodel */; }; + CE0FBB122D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB092D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataProperties.swift */; }; + CE0FBB132D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0B2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataProperties.swift */; }; + CE0FBB142D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0A2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataClass.swift */; }; + CE0FBB152D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB082D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataClass.swift */; }; + CE0FBB162D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0D2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataProperties.swift */; }; + CE0FBB172D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0C2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataClass.swift */; }; + CE0FBB182D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0E2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataClass.swift */; }; + CE0FBB192D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB0F2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataProperties.swift */; }; + CE0FBB1A2D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB102D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataClass.swift */; }; + CE0FBB1B2D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB112D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataProperties.swift */; }; + CE0FBB282D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB272D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataProperties.swift */; }; + CE0FBB292D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB262D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataClass.swift */; }; CE12FBE32220515600C59248 /* WooCommerceModelV9toV10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = CE12FBE22220515600C59248 /* WooCommerceModelV9toV10.xcmappingmodel */; }; CE3B7AD22225E62C0050FE4B /* OrderStatus+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3B7AD02225E62C0050FE4B /* OrderStatus+CoreDataClass.swift */; }; CE3B7AD32225E62C0050FE4B /* OrderStatus+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3B7AD12225E62C0050FE4B /* OrderStatus+CoreDataProperties.swift */; }; @@ -505,6 +517,19 @@ CCBEBD3F27C68E660010C96F /* FeatureIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIcon.swift; sourceTree = ""; }; CCD2E70625DE9AAA00BD975D /* WooCommerceModelV45toV46.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = WooCommerceModelV45toV46.xcmappingmodel; sourceTree = ""; }; CCF3209E2927EBEE002114B1 /* Model 78.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 78.xcdatamodel"; sourceTree = ""; }; + CE0FBB072D0C4420008B7789 /* Model 119.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 119.xcdatamodel"; sourceTree = ""; }; + CE0FBB082D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCarrierPredefinedOptions+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB092D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCarrierPredefinedOptions+CoreDataProperties.swift"; sourceTree = ""; }; + CE0FBB0A2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCustomPackage+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB0B2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCustomPackage+CoreDataProperties.swift"; sourceTree = ""; }; + CE0FBB0C2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedOption+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB0D2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedOption+CoreDataProperties.swift"; sourceTree = ""; }; + CE0FBB0E2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedPackage+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB0F2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedPackage+CoreDataProperties.swift"; sourceTree = ""; }; + CE0FBB102D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingSavedPredefinedPackage+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB112D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingSavedPredefinedPackage+CoreDataProperties.swift"; sourceTree = ""; }; + CE0FBB262D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPackagesResponse+CoreDataClass.swift"; sourceTree = ""; }; + CE0FBB272D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPackagesResponse+CoreDataProperties.swift"; sourceTree = ""; }; CE12FBE22220515600C59248 /* WooCommerceModelV9toV10.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = WooCommerceModelV9toV10.xcmappingmodel; sourceTree = ""; }; CE13681229FA8E6500EBF43C /* Model 86.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 86.xcdatamodel"; sourceTree = ""; }; CE1999302A1C22B20093F863 /* Model 88.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Model 88.xcdatamodel"; sourceTree = ""; }; @@ -978,6 +1003,18 @@ CC2C0305262DCC6900928C9C /* ShippingLabelAccountSettings+CoreDataProperties.swift */, CC2C0306262DCC6900928C9C /* ShippingLabelPaymentMethod+CoreDataClass.swift */, CC2C0308262DCC6900928C9C /* ShippingLabelPaymentMethod+CoreDataProperties.swift */, + CE0FBB082D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataClass.swift */, + CE0FBB092D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataProperties.swift */, + CE0FBB0A2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataClass.swift */, + CE0FBB0B2D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataProperties.swift */, + CE0FBB0C2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataClass.swift */, + CE0FBB0D2D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataProperties.swift */, + CE0FBB0E2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataClass.swift */, + CE0FBB0F2D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataProperties.swift */, + CE0FBB102D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataClass.swift */, + CE0FBB112D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataProperties.swift */, + CE0FBB262D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataClass.swift */, + CE0FBB272D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataProperties.swift */, 57A8819E24CA395000AE0943 /* AppSettings */, 025CA2BB238EB86200B05C81 /* ProductShippingClass+CoreDataClass.swift */, 025CA2BC238EB86200B05C81 /* ProductShippingClass+CoreDataProperties.swift */, @@ -1405,6 +1442,8 @@ 45E462072684BCEE00011BF2 /* StateOfACountry+CoreDataClass.swift in Sources */, CCD2E70725DE9AAA00BD975D /* WooCommerceModelV45toV46.xcmappingmodel in Sources */, 26577519243D808B003168A5 /* WooCommerceModelV26toV27.xcmappingmodel in Sources */, + CE0FBB282D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataProperties.swift in Sources */, + CE0FBB292D0C936E008B7789 /* WooShippingPackagesResponse+CoreDataClass.swift in Sources */, EE8A303F2B7352D3001D7C66 /* OrderAttributionInfo+CoreDataProperties.swift in Sources */, 457E6E8327D8B60F00173F69 /* Order+CoreDataProperties.swift in Sources */, CE4FD4492350EB7600A16B31 /* OrderItemTax+CoreDataClass.swift in Sources */, @@ -1457,6 +1496,16 @@ 031C1EA427AD3AFE00298699 /* WCPayCardPresentPaymentDetails+CoreDataProperties.swift in Sources */, B505F6E020BEEA8100BB1B69 /* StorageType.swift in Sources */, 747453A82242C85E00E0B5EE /* ProductTag+CoreDataProperties.swift in Sources */, + CE0FBB122D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataProperties.swift in Sources */, + CE0FBB132D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataProperties.swift in Sources */, + CE0FBB142D0C4AAC008B7789 /* WooShippingCustomPackage+CoreDataClass.swift in Sources */, + CE0FBB152D0C4AAC008B7789 /* WooShippingCarrierPredefinedOptions+CoreDataClass.swift in Sources */, + CE0FBB162D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataProperties.swift in Sources */, + CE0FBB172D0C4AAC008B7789 /* WooShippingPredefinedOption+CoreDataClass.swift in Sources */, + CE0FBB182D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataClass.swift in Sources */, + CE0FBB192D0C4AAC008B7789 /* WooShippingPredefinedPackage+CoreDataProperties.swift in Sources */, + CE0FBB1A2D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataClass.swift in Sources */, + CE0FBB1B2D0C4AAC008B7789 /* WooShippingSavedPredefinedPackage+CoreDataProperties.swift in Sources */, D8FBFF5A22D66A06006E3336 /* OrderStatsV4+CoreDataProperties.swift in Sources */, D88E234125AE08F10023F3B1 /* OrderFeeLine+CoreDataProperties.swift in Sources */, 0371C38028781E2700277E2C /* FeatureAnnouncementCampaign.swift in Sources */, @@ -2018,6 +2067,7 @@ DEC51AA4275B41BE009F3DF4 /* WooCommerce.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + CE0FBB072D0C4420008B7789 /* Model 119.xcdatamodel */, B99824A12CCBA8120090DDF3 /* Model 118.xcdatamodel */, 454AE50A2C8878B800D3C6FE /* Model 117.xcdatamodel */, EE7448702C856E2400947FF4 /* Model 116.xcdatamodel */, @@ -2137,7 +2187,7 @@ DEC51ADE275B41BE009F3DF4 /* Model 47.xcdatamodel */, DEC51ADF275B41BE009F3DF4 /* Model 19.xcdatamodel */, ); - currentVersion = B99824A12CCBA8120090DDF3 /* Model 118.xcdatamodel */; + currentVersion = CE0FBB072D0C4420008B7789 /* Model 119.xcdatamodel */; path = WooCommerce.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Storage/Storage/CoreData/CoreDataManager.swift b/Storage/Storage/CoreData/CoreDataManager.swift index 1829f953c14..835a435df75 100644 --- a/Storage/Storage/CoreData/CoreDataManager.swift +++ b/Storage/Storage/CoreData/CoreDataManager.swift @@ -65,7 +65,7 @@ public final class CoreDataManager: StorageManagerType { return context }() - self.writerDerivedStorage = { + self.writerStorage = { let backgroundContext = persistentContainer.newBackgroundContext() backgroundContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy return backgroundContext @@ -87,23 +87,14 @@ public final class CoreDataManager: StorageManagerType { /// public let viewStorage: StorageType - /// Returns a shared derived storage instance dedicated for write operations. + /// Storage instance dedicated for write operations. /// - public let writerDerivedStorage: StorageType + private let writerStorage: StorageType /// Persistent Container: Holds the full CoreData Stack /// public let persistentContainer: NSPersistentContainer - /// Saves the derived storage. Note: the closure may be called on a different thread - /// - public func saveDerivedType(derivedStorage: StorageType, _ closure: @escaping () -> Void) { - derivedStorage.perform { - derivedStorage.saveIfNeeded() - closure() - } - } - /// Execute the given operation with a background context and save the changes. /// /// This function _does not block_ its running thread. The operation is executed in background and its return value @@ -116,12 +107,12 @@ public final class CoreDataManager: StorageManagerType { public func performAndSave(_ operation: @escaping (StorageType) -> Void, completion: (() -> Void)?, on queue: DispatchQueue) { - let derivedStorage = writerDerivedStorage + let derivedStorage = writerStorage writerQueue.addOperation(AsyncBlockOperation { done in derivedStorage.perform { operation(derivedStorage) - derivedStorage.saveIfNeeded() + (derivedStorage as! NSManagedObjectContext).saveIfNeeded() queue.async { completion?() } done() } @@ -142,12 +133,12 @@ public final class CoreDataManager: StorageManagerType { completion: @escaping (Result) -> Void, on queue: DispatchQueue) { assert((T.self is NSManagedObject.Type) == false, "Managed objects should not be sent between different contexts to avoid threading issues.") - let derivedStorage = writerDerivedStorage + let derivedStorage = writerStorage writerQueue.addOperation(AsyncBlockOperation { done in derivedStorage.perform { let result = Result(catching: { try operation(derivedStorage) }) if case .success = result { - derivedStorage.saveIfNeeded() + (derivedStorage as! NSManagedObjectContext).saveIfNeeded() } queue.async { completion(result) } done() @@ -155,29 +146,19 @@ public final class CoreDataManager: StorageManagerType { }) } - /// This method effectively destroys all of the stored data, and generates a blank Persistent Store from scratch. + /// This method effectively **deletes** all of the stored data from the persistent container, + /// and generates a blank Persistent Store from scratch. /// - public func reset() { - /// Reset the view context first - let viewContext = persistentContainer.viewContext - viewContext.performAndWait { - viewContext.reset() - self.deleteAllStoredObjects(in: viewContext) - viewContext.saveIfNeeded() - } - + public func reset(onCompletion: (() -> Void)?) { /// Delete all objects in the background context to avoid discrepancy with the view context + /// The view context will get updated automatically once the changes are saved to the persistent container. performAndSave({ storage in - guard let backgroundContext = storage as? NSManagedObjectContext else { - DDLogError("⛔️ CoreDataManager failed to reset due to unexpected storage type!") - return - } + let backgroundContext = storage as! NSManagedObjectContext /// persist self to complete deleting objects self.deleteAllStoredObjects(in: backgroundContext) - backgroundContext.reset() }, completion: { DDLogVerbose("💣 [CoreDataManager] Stack Destroyed!") - NotificationCenter.default.post(name: .StorageManagerDidResetStorage, object: self) + onCompletion?() }, on: .main) } diff --git a/Storage/Storage/Model/MIGRATIONS.md b/Storage/Storage/Model/MIGRATIONS.md index 7041d2dd472..b0093efadac 100644 --- a/Storage/Storage/Model/MIGRATIONS.md +++ b/Storage/Storage/Model/MIGRATIONS.md @@ -2,6 +2,15 @@ This file documents changes in the WCiOS Storage data model. Please explain any changes to the data model as well as any custom migrations. +## Model 119 (Release 21.3.0.0) +- @rachelmcr 2024-12-16 + - Added `WooShippingPackagesResponse` entity. + - Added `WooShippingCarrierPredefinedOptions` entity. + - Added `WooShippingPredefinedOption` entity. + - Added `WooShippingPredefinedPackage` entity. + - Added `WooShippingCustomPackage` entity. + - Added `WooShippingSavedPredefinedPackage` entity. + ## Model 118 (Release 21.0.0.0) - @cvargascasaseca 2024-10-25 - Added `globalUniqueID` attribute to `Product` and `ProductVariation` entities. diff --git a/Storage/Storage/Model/ProductVariation+CoreDataProperties.swift b/Storage/Storage/Model/ProductVariation+CoreDataProperties.swift index ee53996a75d..3e1f4fdd099 100644 --- a/Storage/Storage/Model/ProductVariation+CoreDataProperties.swift +++ b/Storage/Storage/Model/ProductVariation+CoreDataProperties.swift @@ -9,7 +9,7 @@ extension ProductVariation { } @NSManaged public var dateModified: Date? - @NSManaged public var dateCreated: Date + @NSManaged public var dateCreated: Date? @NSManaged public var fullDescription: String? @NSManaged public var permalink: String @NSManaged public var productVariationID: Int64 diff --git a/Storage/Storage/Model/ShippingLabel+CoreDataProperties.swift b/Storage/Storage/Model/ShippingLabel+CoreDataProperties.swift index 888a6ebcbd9..dd0e2b091e1 100644 --- a/Storage/Storage/Model/ShippingLabel+CoreDataProperties.swift +++ b/Storage/Storage/Model/ShippingLabel+CoreDataProperties.swift @@ -12,7 +12,7 @@ extension ShippingLabel { @NSManaged public var orderID: Int64 @NSManaged public var shippingLabelID: Int64 @NSManaged public var carrierID: String - @NSManaged public var dateCreated: Date + @NSManaged public var dateCreated: Date? @NSManaged public var packageName: String @NSManaged public var rate: Double @NSManaged public var currency: String diff --git a/Storage/Storage/Model/ShippingLabelRefund+CoreDataProperties.swift b/Storage/Storage/Model/ShippingLabelRefund+CoreDataProperties.swift index a7183a637ae..72571c763f9 100644 --- a/Storage/Storage/Model/ShippingLabelRefund+CoreDataProperties.swift +++ b/Storage/Storage/Model/ShippingLabelRefund+CoreDataProperties.swift @@ -8,7 +8,7 @@ extension ShippingLabelRefund { return NSFetchRequest(entityName: "ShippingLabelRefund") } - @NSManaged public var dateRequested: Date + @NSManaged public var dateRequested: Date? @NSManaged public var status: String @NSManaged public var shippingLabel: ShippingLabel? diff --git a/Storage/Storage/Model/WCPayCharge+CoreDataProperties.swift b/Storage/Storage/Model/WCPayCharge+CoreDataProperties.swift index 5fb4fa0c700..b6118910053 100644 --- a/Storage/Storage/Model/WCPayCharge+CoreDataProperties.swift +++ b/Storage/Storage/Model/WCPayCharge+CoreDataProperties.swift @@ -15,7 +15,7 @@ extension WCPayCharge { @NSManaged public var amountRefunded: Int64 @NSManaged public var authorizationCode: String? @NSManaged public var captured: Bool - @NSManaged public var created: Date + @NSManaged public var created: Date? @NSManaged public var currency: String @NSManaged public var paid: Bool @NSManaged public var paymentIntentID: String? diff --git a/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion b/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion index c47ca155bd7..6343cdfd9f8 100644 --- a/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion +++ b/Storage/Storage/Model/WooCommerce.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model 118.xcdatamodel + Model 119.xcdatamodel diff --git a/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 118.xcdatamodel/contents b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 118.xcdatamodel/contents index 22d13490962..827bb19220e 100644 --- a/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 118.xcdatamodel/contents +++ b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 118.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -75,9 +75,9 @@ - - - + + + @@ -85,13 +85,13 @@ - - + + - + @@ -408,7 +408,7 @@ - + @@ -428,7 +428,7 @@ - + @@ -443,7 +443,7 @@ - + @@ -456,7 +456,7 @@ - + @@ -475,7 +475,7 @@ - + @@ -492,8 +492,8 @@ - - + + @@ -542,7 +542,7 @@ - + @@ -559,7 +559,7 @@ - + @@ -588,7 +588,7 @@ - + @@ -757,8 +757,8 @@ - - + + @@ -850,7 +850,7 @@ - + @@ -928,7 +928,7 @@ - + @@ -936,7 +936,7 @@ - + diff --git a/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 119.xcdatamodel/contents b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 119.xcdatamodel/contents new file mode 100644 index 00000000000..d9373ac059d --- /dev/null +++ b/Storage/Storage/Model/WooCommerce.xcdatamodeld/Model 119.xcdatamodel/contents @@ -0,0 +1,1065 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataClass.swift b/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataClass.swift new file mode 100644 index 00000000000..27a6a1923d6 --- /dev/null +++ b/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingCarrierPredefinedOptions) +public class WooShippingCarrierPredefinedOptions: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataProperties.swift new file mode 100644 index 00000000000..5ac8a43814a --- /dev/null +++ b/Storage/Storage/Model/WooShippingCarrierPredefinedOptions+CoreDataProperties.swift @@ -0,0 +1,54 @@ +import Foundation +import CoreData + + +extension WooShippingCarrierPredefinedOptions { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingCarrierPredefinedOptions") + } + + @NSManaged public var carrierID: String? + @NSManaged public var packagesResponse: WooShippingPackagesResponse? + @NSManaged public var predefinedOptions: NSOrderedSet? + +} + +// MARK: Generated accessors for predefinedOptions +extension WooShippingCarrierPredefinedOptions { + + @objc(insertObject:inPredefinedOptionsAtIndex:) + @NSManaged public func insertIntoPredefinedOptions(_ value: WooShippingPredefinedOption, at idx: Int) + + @objc(removeObjectFromPredefinedOptionsAtIndex:) + @NSManaged public func removeFromPredefinedOptions(at idx: Int) + + @objc(insertPredefinedOptions:atIndexes:) + @NSManaged public func insertIntoPredefinedOptions(_ values: [WooShippingPredefinedOption], at indexes: NSIndexSet) + + @objc(removePredefinedOptionsAtIndexes:) + @NSManaged public func removeFromPredefinedOptions(at indexes: NSIndexSet) + + @objc(replaceObjectInPredefinedOptionsAtIndex:withObject:) + @NSManaged public func replacePredefinedOptions(at idx: Int, with value: WooShippingPredefinedOption) + + @objc(replacePredefinedOptionsAtIndexes:withPredefinedOptions:) + @NSManaged public func replacePredefinedOptions(at indexes: NSIndexSet, with values: [WooShippingPredefinedOption]) + + @objc(addPredefinedOptionsObject:) + @NSManaged public func addToPredefinedOptions(_ value: WooShippingPredefinedOption) + + @objc(removePredefinedOptionsObject:) + @NSManaged public func removeFromPredefinedOptions(_ value: WooShippingPredefinedOption) + + @objc(addPredefinedOptions:) + @NSManaged public func addToPredefinedOptions(_ values: NSOrderedSet) + + @objc(removePredefinedOptions:) + @NSManaged public func removeFromPredefinedOptions(_ values: NSOrderedSet) + +} + +extension WooShippingCarrierPredefinedOptions: Identifiable { + +} diff --git a/Storage/Storage/Model/WooShippingCustomPackage+CoreDataClass.swift b/Storage/Storage/Model/WooShippingCustomPackage+CoreDataClass.swift new file mode 100644 index 00000000000..aefd1b08ac5 --- /dev/null +++ b/Storage/Storage/Model/WooShippingCustomPackage+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingCustomPackage) +public class WooShippingCustomPackage: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingCustomPackage+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingCustomPackage+CoreDataProperties.swift new file mode 100644 index 00000000000..26218c8ad95 --- /dev/null +++ b/Storage/Storage/Model/WooShippingCustomPackage+CoreDataProperties.swift @@ -0,0 +1,22 @@ +import Foundation +import CoreData + + +extension WooShippingCustomPackage { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingCustomPackage") + } + + @NSManaged public var boxWeight: Double + @NSManaged public var dimensions: String? + @NSManaged public var id: String? + @NSManaged public var name: String? + @NSManaged public var rawType: String? + @NSManaged public var packagesResponse: WooShippingPackagesResponse? + +} + +extension WooShippingCustomPackage: Identifiable { + +} diff --git a/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataClass.swift b/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataClass.swift new file mode 100644 index 00000000000..5f96b04c87c --- /dev/null +++ b/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingPackagesResponse) +public class WooShippingPackagesResponse: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataProperties.swift new file mode 100644 index 00000000000..73670148cfc --- /dev/null +++ b/Storage/Storage/Model/WooShippingPackagesResponse+CoreDataProperties.swift @@ -0,0 +1,89 @@ +import Foundation +import CoreData + + +extension WooShippingPackagesResponse { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingPackagesResponse") + } + + @NSManaged public var siteID: Int64 + @NSManaged public var allPredefinedOptions: NSOrderedSet? + @NSManaged public var customPackages: Set? + @NSManaged public var savedPredefinedPackages: Set? + +} + +// MARK: Generated accessors for allPredefinedOptions +extension WooShippingPackagesResponse { + + @objc(insertObject:inAllPredefinedOptionsAtIndex:) + @NSManaged public func insertIntoAllPredefinedOptions(_ value: WooShippingCarrierPredefinedOptions, at idx: Int) + + @objc(removeObjectFromAllPredefinedOptionsAtIndex:) + @NSManaged public func removeFromAllPredefinedOptions(at idx: Int) + + @objc(insertAllPredefinedOptions:atIndexes:) + @NSManaged public func insertIntoAllPredefinedOptions(_ values: [WooShippingCarrierPredefinedOptions], at indexes: NSIndexSet) + + @objc(removeAllPredefinedOptionsAtIndexes:) + @NSManaged public func removeFromAllPredefinedOptions(at indexes: NSIndexSet) + + @objc(replaceObjectInAllPredefinedOptionsAtIndex:withObject:) + @NSManaged public func replaceAllPredefinedOptions(at idx: Int, with value: WooShippingCarrierPredefinedOptions) + + @objc(replaceAllPredefinedOptionsAtIndexes:withAllPredefinedOptions:) + @NSManaged public func replaceAllPredefinedOptions(at indexes: NSIndexSet, with values: [WooShippingCarrierPredefinedOptions]) + + @objc(addAllPredefinedOptionsObject:) + @NSManaged public func addToAllPredefinedOptions(_ value: WooShippingCarrierPredefinedOptions) + + @objc(removeAllPredefinedOptionsObject:) + @NSManaged public func removeFromAllPredefinedOptions(_ value: WooShippingCarrierPredefinedOptions) + + @objc(addAllPredefinedOptions:) + @NSManaged public func addToAllPredefinedOptions(_ values: NSOrderedSet) + + @objc(removeAllPredefinedOptions:) + @NSManaged public func removeFromAllPredefinedOptions(_ values: NSOrderedSet) + +} + +// MARK: Generated accessors for customPackages +extension WooShippingPackagesResponse { + + @objc(addCustomPackagesObject:) + @NSManaged public func addToCustomPackages(_ value: WooShippingCustomPackage) + + @objc(removeCustomPackagesObject:) + @NSManaged public func removeFromCustomPackages(_ value: WooShippingCustomPackage) + + @objc(addCustomPackages:) + @NSManaged public func addToCustomPackages(_ values: NSSet) + + @objc(removeCustomPackages:) + @NSManaged public func removeFromCustomPackages(_ values: NSSet) + +} + +// MARK: Generated accessors for savedPredefinedPackages +extension WooShippingPackagesResponse { + + @objc(addSavedPredefinedPackagesObject:) + @NSManaged public func addToSavedPredefinedPackages(_ value: WooShippingSavedPredefinedPackage) + + @objc(removeSavedPredefinedPackagesObject:) + @NSManaged public func removeFromSavedPredefinedPackages(_ value: WooShippingSavedPredefinedPackage) + + @objc(addSavedPredefinedPackages:) + @NSManaged public func addToSavedPredefinedPackages(_ values: NSSet) + + @objc(removeSavedPredefinedPackages:) + @NSManaged public func removeFromSavedPredefinedPackages(_ values: NSSet) + +} + +extension WooShippingPackagesResponse: Identifiable { + +} diff --git a/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataClass.swift b/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataClass.swift new file mode 100644 index 00000000000..2751fb055fd --- /dev/null +++ b/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingPredefinedOption) +public class WooShippingPredefinedOption: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataProperties.swift new file mode 100644 index 00000000000..65004dcaea9 --- /dev/null +++ b/Storage/Storage/Model/WooShippingPredefinedOption+CoreDataProperties.swift @@ -0,0 +1,37 @@ +import Foundation +import CoreData + + +extension WooShippingPredefinedOption { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingPredefinedOption") + } + + @NSManaged public var providerID: String? + @NSManaged public var title: String? + @NSManaged public var carrier: WooShippingCarrierPredefinedOptions? + @NSManaged public var predefinedPackages: Set? + +} + +// MARK: Generated accessors for predefinedPackages +extension WooShippingPredefinedOption { + + @objc(addPredefinedPackagesObject:) + @NSManaged public func addToPredefinedPackages(_ value: WooShippingPredefinedPackage) + + @objc(removePredefinedPackagesObject:) + @NSManaged public func removeFromPredefinedPackages(_ value: WooShippingPredefinedPackage) + + @objc(addPredefinedPackages:) + @NSManaged public func addToPredefinedPackages(_ values: NSSet) + + @objc(removePredefinedPackages:) + @NSManaged public func removeFromPredefinedPackages(_ values: NSSet) + +} + +extension WooShippingPredefinedOption: Identifiable { + +} diff --git a/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataClass.swift b/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataClass.swift new file mode 100644 index 00000000000..eeb08efca52 --- /dev/null +++ b/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingPredefinedPackage) +public class WooShippingPredefinedPackage: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataProperties.swift new file mode 100644 index 00000000000..7824c608d24 --- /dev/null +++ b/Storage/Storage/Model/WooShippingPredefinedPackage+CoreDataProperties.swift @@ -0,0 +1,24 @@ +import Foundation +import CoreData + + +extension WooShippingPredefinedPackage { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingPredefinedPackage") + } + + @NSManaged public var boxWeight: String? + @NSManaged public var dimensions: String? + @NSManaged public var groupID: String? + @NSManaged public var id: String? + @NSManaged public var isLetter: Bool + @NSManaged public var name: String? + @NSManaged public var predefinedOption: WooShippingPredefinedOption? + @NSManaged public var savedPredefinedPackage: WooShippingSavedPredefinedPackage? + +} + +extension WooShippingPredefinedPackage: Identifiable { + +} diff --git a/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataClass.swift b/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataClass.swift new file mode 100644 index 00000000000..9480cc1a0b3 --- /dev/null +++ b/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(WooShippingSavedPredefinedPackage) +public class WooShippingSavedPredefinedPackage: NSManagedObject { + +} diff --git a/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataProperties.swift b/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataProperties.swift new file mode 100644 index 00000000000..ed57cc5e67f --- /dev/null +++ b/Storage/Storage/Model/WooShippingSavedPredefinedPackage+CoreDataProperties.swift @@ -0,0 +1,20 @@ +import Foundation +import CoreData + + +extension WooShippingSavedPredefinedPackage { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "WooShippingSavedPredefinedPackage") + } + + @NSManaged public var groupTitle: String? + @NSManaged public var providerID: String? + @NSManaged public var package: WooShippingPredefinedPackage? + @NSManaged public var packagesResponse: WooShippingPackagesResponse? + +} + +extension WooShippingSavedPredefinedPackage: Identifiable { + +} diff --git a/Storage/Storage/Protocols/StorageManagerType.swift b/Storage/Storage/Protocols/StorageManagerType.swift index 1e1f5f789dc..95d10e57e34 100644 --- a/Storage/Storage/Protocols/StorageManagerType.swift +++ b/Storage/Storage/Protocols/StorageManagerType.swift @@ -16,24 +16,6 @@ public protocol StorageManagerType: AnyObject { /// var viewStorage: StorageType { get } - /// Returns a shared derived storage instance dedicated for write operations. - /// - @available(*, deprecated, message: "Use `performAndSave` to handle write operations instead.") - var writerDerivedStorage: StorageType { get } - - /// Performs a task in Background: a special `Storage` instance will be provided (which is expected to be used within the closure!). - /// Note that you must NEVER use the viewStorage within the backgroundClosure. - /// - - /// Save a derived context created with `writerDerivedStorage` via this convenience method - /// - /// - Parameters: - /// - derivedStorageType: a derived StorageType constructed with `newDerivedStorage` - /// - closure: Callback to be executed on completion - /// - @available(*, deprecated, message: "Use `performAndSave` to handle write operations instead.") - func saveDerivedType(derivedStorage: StorageType, _ closure: @escaping () -> Void) - /// Execute the given operation with a background context and save the changes. /// /// This function _does not block_ its running thread. The operation is executed in background and its return value @@ -64,5 +46,14 @@ public protocol StorageManagerType: AnyObject { /// This method is expected to destroy all persisted data. A notification of type `StorageManagerDidResetStorage` should get /// posted. /// - func reset() + /// - Parameter onCompletion: A callback closure triggered when the resetting is done. + /// + func reset(onCompletion: (() -> Void)?) +} + +public extension StorageManagerType { + /// Simplified method to reset the storage. + func reset() { + reset(onCompletion: nil) + } } diff --git a/Storage/Storage/Protocols/StorageType.swift b/Storage/Storage/Protocols/StorageType.swift index c9ae02eb0e7..0a413834a1e 100644 --- a/Storage/Storage/Protocols/StorageType.swift +++ b/Storage/Storage/Protocols/StorageType.swift @@ -56,10 +56,6 @@ public protocol StorageType: AnyObject { /// This will ensure that `NSFetchedResultsController` will not ever receive temporary IDs. func obtainPermanentIDs(for objects: [NSManagedObject]) throws - /// Persists unsaved changes, if needed. - /// - func saveIfNeeded() - /// Asynchronously performs a given block on the StorageType's queue. /// func perform(_ closure: @escaping () -> Void) diff --git a/Storage/Storage/Tools/StorageType+Deletions.swift b/Storage/Storage/Tools/StorageType+Deletions.swift index 43592fbfe3c..ea52c80c601 100644 --- a/Storage/Storage/Tools/StorageType+Deletions.swift +++ b/Storage/Storage/Tools/StorageType+Deletions.swift @@ -184,15 +184,6 @@ public extension StorageType { } } - /// Deletes all of the stored `AddOnGroups` for a `siteID` that are not included in the provided `activeGroupIDs` array. - /// - func deleteStaleAddOnGroups(siteID: Int64, activeGroupIDs: [Int64]) { - let staleGroups = loadAddOnGroups(siteID: siteID).filter { !activeGroupIDs.contains($0.groupID) } - staleGroups.forEach { - deleteObject($0) - } - } - /// Deletes all of the stored `SitePlugin` entities with a specified `siteID` whose name is not included in `installedPluginNames` array. /// func deleteStalePlugins(siteID: Int64, installedPluginNames: [String]) { diff --git a/Storage/Storage/Tools/StorageType+Extensions.swift b/Storage/Storage/Tools/StorageType+Extensions.swift index 0a1da1a748c..7e7529ecc4e 100644 --- a/Storage/Storage/Tools/StorageType+Extensions.swift +++ b/Storage/Storage/Tools/StorageType+Extensions.swift @@ -545,6 +545,62 @@ public extension StorageType { return firstObject(ofType: ShippingLabelSettings.self, matching: predicate) } + // MARK: - Woo Shipping + + /// Returns all stored Woo Shipping packages for a site. + /// + func loadPackages(siteID: Int64) -> WooShippingPackagesResponse? { + let predicate = \WooShippingPackagesResponse.siteID == siteID + return firstObject(ofType: WooShippingPackagesResponse.self, matching: predicate) + } + + /// Returns all stored Woo Shipping carrier predefined options for a given `WooShippingPackagesResponse`. + /// + func loadAllPredefinedOptions(packagesResponse: WooShippingPackagesResponse) -> [WooShippingCarrierPredefinedOptions] { + let predicate = \WooShippingCarrierPredefinedOptions.packagesResponse == packagesResponse + let descriptor = NSSortDescriptor(keyPath: \WooShippingCarrierPredefinedOptions.carrierID, ascending: true) + return allObjects(ofType: WooShippingCarrierPredefinedOptions.self, matching: predicate, sortedBy: [descriptor]) + } + + /// Returns all stored Woo Shipping predefined options for a given `WooShippingCarrierPredefinedOptions`. + /// + func predefinedOptions(carrier: WooShippingCarrierPredefinedOptions) -> [WooShippingPredefinedOption] { + let predicate = \WooShippingPredefinedOption.carrier == carrier + let descriptor = NSSortDescriptor(keyPath: \WooShippingPredefinedOption.title, ascending: true) + return allObjects(ofType: WooShippingPredefinedOption.self, matching: predicate, sortedBy: [descriptor]) + } + + /// Returns all stored Woo Shipping predefined packages for a given `WooShippingPredefinedOption`. + /// + func predefinedPackages(predefinedOption: WooShippingPredefinedOption) -> [WooShippingPredefinedPackage] { + let predicate = \WooShippingPredefinedPackage.predefinedOption == predefinedOption + let descriptor = NSSortDescriptor(keyPath: \WooShippingPredefinedPackage.id, ascending: true) + return allObjects(ofType: WooShippingPredefinedPackage.self, matching: predicate, sortedBy: [descriptor]) + } + + /// Returns all stored Woo Shipping saved predefined packages for a given `WooShippingPackagesResponse`. + /// + func loadSavedPredefinedPackages(packagesResponse: WooShippingPackagesResponse) -> [WooShippingSavedPredefinedPackage] { + let predicate = \WooShippingSavedPredefinedPackage.packagesResponse == packagesResponse + let descriptor = NSSortDescriptor(keyPath: \WooShippingSavedPredefinedPackage.providerID, ascending: true) + return allObjects(ofType: WooShippingSavedPredefinedPackage.self, matching: predicate, sortedBy: [descriptor]) + } + + /// Returns stored Woo Shipping predefined package for a given `WooShippingSavedPredefinedPackage`. + /// + func loadPredefinedPackage(savedPredefinedPackage: WooShippingSavedPredefinedPackage) -> WooShippingPredefinedPackage? { + let predicate = \WooShippingPredefinedPackage.savedPredefinedPackage == savedPredefinedPackage + return firstObject(ofType: WooShippingPredefinedPackage.self, matching: predicate) + } + + /// Returns all stored Woo Shipping custom packages for a given `WooShippingPackagesResponse`. + /// + func loadCustomPackages(packagesResponse: WooShippingPackagesResponse) -> [WooShippingCustomPackage] { + let predicate = \WooShippingCustomPackage.packagesResponse == packagesResponse + let descriptor = NSSortDescriptor(keyPath: \WooShippingCustomPackage.id, ascending: true) + return allObjects(ofType: WooShippingCustomPackage.self, matching: predicate, sortedBy: [descriptor]) + } + // MARK: - BlazeCampaignListItem /// Returns a single BlazeCampaignListItem given a `siteID` and `campaignID` @@ -710,6 +766,13 @@ public extension StorageType { return allObjects(ofType: Customer.self, matching: predicate, sortedBy: []) } + /// Returns stored Customers given a `siteID` matching `customerIDs` + /// + func loadCustomers(siteID: Int64, matching customerIDs: [Int64]) -> [Customer] { + let predicate = NSPredicate(format: "siteID == %lld && customerID in %@", siteID, customerIDs) + return allObjects(ofType: Customer.self, matching: predicate, sortedBy: []) + } + /// Returns a CustomerSearchResult given a `siteID` and a `keyword` /// func loadCustomerSearchResult(siteID: Int64, keyword: String) -> CustomerSearchResult? { @@ -731,6 +794,13 @@ public extension StorageType { return allObjects(ofType: WCAnalyticsCustomer.self, matching: predicate, sortedBy: []) } + /// Returns stored WCAnalyticsCustomer given a `siteID` matching `customerIDs` + /// + func loadWCAnalyticsCustomers(siteID: Int64, matching customerIDs: [Int64]) -> [WCAnalyticsCustomer] { + let predicate = NSPredicate(format: "siteID == %lld && customerID in %@", siteID, customerIDs) + return allObjects(ofType: WCAnalyticsCustomer.self, matching: predicate, sortedBy: []) + } + /// Returns a WCAnalyticsCustomerSearchResult given a `siteID` and a `keyword` /// func loadWCAnalyticsCustomerSearchResult(siteID: Int64, keyword: String) -> WCAnalyticsCustomerSearchResult? { diff --git a/Storage/StorageTests/CoreData/CoreDataManagerTests.swift b/Storage/StorageTests/CoreData/CoreDataManagerTests.swift index bbd981709bf..3ed9bd165f5 100644 --- a/Storage/StorageTests/CoreData/CoreDataManagerTests.swift +++ b/Storage/StorageTests/CoreData/CoreDataManagerTests.swift @@ -41,19 +41,6 @@ final class CoreDataManagerTests: XCTestCase { XCTAssertEqual(manager.viewStorage as? NSManagedObjectContext, manager.persistentContainer.viewContext) } - /// Verifies that derived context is instantiated correctly. - /// - func test_derived_storage_is_instantiated_correctly() { - let manager = CoreDataManager(name: storageIdentifier, crashLogger: MockCrashLogger()) - let viewContext = (manager.viewStorage as? NSManagedObjectContext) - let derivedContext = (manager.writerDerivedStorage as? NSManagedObjectContext) - - XCTAssertNotNil(viewContext) - XCTAssertNotNil(derivedContext) - XCTAssertNotEqual(derivedContext, viewContext) - XCTAssertNil(derivedContext?.parent) - } - func test_resetting_CoreData_deletes_preexisting_objects() throws { // Arrange let modelsInventory = try makeModelsInventory() @@ -66,8 +53,9 @@ final class CoreDataManagerTests: XCTestCase { _ = storage.insertNewObject(ofType: ShippingLine.self) }, completion: { XCTAssertEqual(viewContext.countObjects(ofType: ShippingLine.self), 1) - manager.reset() - expectation.fulfill() + manager.reset { + expectation.fulfill() + } }, on: .main) } @@ -153,8 +141,13 @@ final class CoreDataManagerTests: XCTestCase { let modelsInventory = try makeModelsInventory() var manager = try makeManager(using: modelsInventory, deletingExistingStoreFiles: true) - insertAccount(to: manager.viewStorage) - manager.viewStorage.saveIfNeeded() + waitFor { promise in + manager.performAndSave({ storage in + self.insertAccount(to: storage) + }, completion: { + promise(()) + }, on: .main) + } XCTAssertEqual(manager.viewStorage.countObjects(ofType: Account.self), 1) XCTAssertNotNil(NSEntityDescription.entity(forEntityName: Note.entityName, @@ -206,8 +199,13 @@ final class CoreDataManagerTests: XCTestCase { var manager = try makeManager(using: olderModelsInventory, deletingExistingStoreFiles: true) - insertAccount(to: manager.viewStorage) - manager.viewStorage.saveIfNeeded() + waitFor { promise in + manager.performAndSave({ storage in + self.insertAccount(to: storage) + }, completion: { + promise(()) + }, on: .main) + } XCTAssertEqual(manager.viewStorage.countObjects(ofType: Account.self), 1) // The ShippineLineTax entity does not exist in Model 33. @@ -242,8 +240,13 @@ final class CoreDataManagerTests: XCTestCase { var manager = try makeManager(using: modelsInventory, deletingExistingStoreFiles: true) - insertAccount(to: manager.viewStorage) - manager.viewStorage.saveIfNeeded() + waitFor { promise in + manager.performAndSave({ storage in + self.insertAccount(to: storage) + }, completion: { + promise(()) + }, on: .main) + } XCTAssertEqual(manager.viewStorage.countObjects(ofType: Account.self), 1) try assertThat(manager, isCompatibleWith: modelsInventory.currentModel) diff --git a/Storage/StorageTests/CoreData/MigrationTests.swift b/Storage/StorageTests/CoreData/MigrationTests.swift index 522fbe787be..4eaf70c02e8 100644 --- a/Storage/StorageTests/CoreData/MigrationTests.swift +++ b/Storage/StorageTests/CoreData/MigrationTests.swift @@ -3146,6 +3146,120 @@ final class MigrationTests: XCTestCase { let savedGlobalUniqueID = try XCTUnwrap(migratedProductVariation.value(forKey: "globalUniqueID") as? String) XCTAssertEqual(savedGlobalUniqueID, globalUniqueID) } + + func test_migrating_from_118_to_119_adds_woo_shipping_entities() throws { + // Given + let sourceContainer = try startPersistentContainer("Model 118") + let sourceContext = sourceContainer.viewContext + + try sourceContext.save() + + // Confidence Check. These entities should not exist in Model 118 + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingPackagesResponse", in: sourceContext)) + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingCarrierPredefinedOptions", in: sourceContext)) + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingCustomPackage", in: sourceContext)) + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingPredefinedOption", in: sourceContext)) + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingPredefinedPackage", in: sourceContext)) + XCTAssertNil(NSEntityDescription.entity(forEntityName: "WooShippingSavedPredefinedPackage", in: sourceContext)) + + // When + let targetContainer = try migrate(sourceContainer, to: "Model 119") + + // Then + let targetContext = targetContainer.viewContext + + // These entities should exist in Model 119 + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingPackagesResponse", in: targetContext)) + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingCarrierPredefinedOptions", in: targetContext)) + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingCustomPackage", in: targetContext)) + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingPredefinedOption", in: targetContext)) + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingPredefinedPackage", in: targetContext)) + XCTAssertNotNil(NSEntityDescription.entity(forEntityName: "WooShippingSavedPredefinedPackage", in: targetContext)) + + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPackagesResponse"), 0) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingCarrierPredefinedOptions"), 0) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingCustomPackage"), 0) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPredefinedOption"), 0) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPredefinedPackage"), 0) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingSavedPredefinedPackage"), 0) + + // Insert a new WooShippingCarrierPackagesResponse + let packagesResponse = insertWooShippingPackagesResponse(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPackagesResponse"), 1) + + // Check all attributes and relationships + XCTAssertNotNil(packagesResponse.entity.attributesByName["siteID"]) + XCTAssertNotNil(packagesResponse.entity.relationshipsByName["allPredefinedOptions"]) + XCTAssertNotNil(packagesResponse.entity.relationshipsByName["customPackages"]) + XCTAssertNotNil(packagesResponse.entity.relationshipsByName["savedPredefinedPackages"]) + + // Insert a new WooShippingCarrierPredefinedOptions + let carrierPredefinedOptions = insertWooShippingCarrierPredefinedOptions(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingCarrierPredefinedOptions"), 1) + + // Check all attributes and relationships + XCTAssertNotNil(carrierPredefinedOptions.entity.attributesByName["carrierID"]) + XCTAssertNotNil(carrierPredefinedOptions.entity.relationshipsByName["predefinedOptions"]) + + // Insert a new WooShippingPredefinedOption + let predefinedOption = insertWooShippingPredefinedOption(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPredefinedOption"), 1) + + // Check all attributes and relationships + XCTAssertNotNil(predefinedOption.entity.attributesByName["providerID"]) + XCTAssertNotNil(predefinedOption.entity.attributesByName["title"]) + XCTAssertNotNil(predefinedOption.entity.relationshipsByName["carrier"]) + XCTAssertNotNil(predefinedOption.entity.relationshipsByName["predefinedPackages"]) + + // Insert a new WooShippingPredefinedPackage + let predefinedPackage = insertWooShippingPredefinedPackage(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingPredefinedPackage"), 1) + + // Check all attributes and relationships + XCTAssertNotNil(predefinedPackage.entity.attributesByName["id"]) + XCTAssertNotNil(predefinedPackage.entity.attributesByName["groupID"]) + XCTAssertNotNil(predefinedPackage.entity.attributesByName["name"]) + XCTAssertNotNil(predefinedPackage.entity.attributesByName["dimensions"]) + XCTAssertNotNil(predefinedPackage.entity.attributesByName["isLetter"]) + XCTAssertNotNil(predefinedPackage.entity.attributesByName["boxWeight"]) + XCTAssertNotNil(predefinedPackage.entity.relationshipsByName["predefinedOption"]) + XCTAssertNotNil(predefinedPackage.entity.relationshipsByName["savedPredefinedPackage"]) + + // Insert a new WooShippingSavedPredefinedPackage + let savedPredefinedPackage = insertWooShippingSavedPredefinedPackage(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingSavedPredefinedPackage"), 1) + + // Check all attributes and relationships + XCTAssertNotNil(savedPredefinedPackage.entity.attributesByName["providerID"]) + XCTAssertNotNil(savedPredefinedPackage.entity.attributesByName["groupTitle"]) + XCTAssertNotNil(savedPredefinedPackage.entity.relationshipsByName["package"]) + + // Insert a new WooShippingCustomPackage + let customPackage = insertWooShippingCustomPackage(to: targetContext) + XCTAssertEqual(try targetContext.count(entityName: "WooShippingCustomPackage"), 1) + + // Check all attributes + XCTAssertNotNil(customPackage.entity.attributesByName["id"]) + XCTAssertNotNil(customPackage.entity.attributesByName["name"]) + XCTAssertNotNil(customPackage.entity.attributesByName["dimensions"]) + XCTAssertNotNil(customPackage.entity.attributesByName["rawType"]) + XCTAssertNotNil(customPackage.entity.attributesByName["boxWeight"]) + + // Check all relationships + packagesResponse.setValue(NSOrderedSet(array: [carrierPredefinedOptions]), forKey: "allPredefinedOptions") + packagesResponse.setValue(NSSet(array: [customPackage]), forKey: "customPackages") + packagesResponse.setValue(NSSet(array: [savedPredefinedPackage]), forKey: "savedPredefinedPackages") + carrierPredefinedOptions.setValue(NSOrderedSet(array: [predefinedOption]), forKey: "predefinedOptions") + predefinedOption.setValue(NSSet(array: [predefinedPackage]), forKey: "predefinedPackages") + savedPredefinedPackage.setValue(predefinedPackage, forKey: "package") + try targetContext.save() + XCTAssertEqual(packagesResponse.value(forKey: "allPredefinedOptions") as? NSOrderedSet, NSOrderedSet(array: [carrierPredefinedOptions])) + XCTAssertEqual(packagesResponse.value(forKey: "customPackages") as? NSSet, NSSet(array: [customPackage])) + XCTAssertEqual(packagesResponse.value(forKey: "savedPredefinedPackages") as? NSSet, NSSet(array: [savedPredefinedPackage])) + XCTAssertEqual(carrierPredefinedOptions.value(forKey: "predefinedOptions") as? NSOrderedSet, NSOrderedSet(array: [predefinedOption])) + XCTAssertEqual(predefinedOption.value(forKey: "predefinedPackages") as? NSSet, NSSet(array: [predefinedPackage])) + XCTAssertEqual(savedPredefinedPackage.value(forKey: "package") as? WooShippingPredefinedPackage, predefinedPackage) + } } // MARK: - Persistent Store Setup and Migrations @@ -3992,4 +4106,57 @@ private extension MigrationTests { "value": "New Metadata Value" ]) } + + @discardableResult + func insertWooShippingPackagesResponse(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingPackagesResponse", properties: [ + "siteID": 1, + ]) + } + + @discardableResult + func insertWooShippingCarrierPredefinedOptions(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingCarrierPredefinedOptions", properties: [ + "carrierID": "usps", + ]) + } + + @discardableResult + func insertWooShippingCustomPackage(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingCustomPackage", properties: [ + "id": "abc123", + "name": "Custom Box", + "dimensions": "12.0 x 12.0 x 12.0", + "rawType": "box", + "boxWeight": 1.0, + ]) + } + + @discardableResult + func insertWooShippingPredefinedOption(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingPredefinedOption", properties: [ + "providerID": "usps", + "title": "USPS Priority Mail Boxes" + ]) + } + + @discardableResult + func insertWooShippingPredefinedPackage(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingPredefinedPackage", properties: [ + "id": "usps", + "groupID": "pri_flat_boxes", + "name": "Small Flat Rate Box", + "dimensions": "8.63 x 5.38 x 1.63", + "isLetter": false, + "boxWeight": "0", + ]) + } + + @discardableResult + func insertWooShippingSavedPredefinedPackage(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "WooShippingSavedPredefinedPackage", properties: [ + "providerID": "usps", + "groupTitle": "USPS Priority Mail Flat Rate Boxes", + ]) + } } diff --git a/Storage/StorageTests/Tools/StorageTypeDeletionsTests.swift b/Storage/StorageTests/Tools/StorageTypeDeletionsTests.swift index 73b4ffb35a8..3f9dc0c2fc6 100644 --- a/Storage/StorageTests/Tools/StorageTypeDeletionsTests.swift +++ b/Storage/StorageTests/Tools/StorageTypeDeletionsTests.swift @@ -99,22 +99,6 @@ final class StorageTypeDeletionsTests: XCTestCase { XCTAssertEqual(storedCoupons, [otherCoupon]) } - func test_deleteStaleAddOnGroups_does_not_delete_active_addOns() throws { - // Given - let initialGroups: [AddOnGroup] = [ - createAddOnGroup(groupID: 123, name: "AAA"), - createAddOnGroup(groupID: 1234, name: "BBB"), - createAddOnGroup(groupID: 12345, name: "CCC") - ] - - // When - storage.deleteStaleAddOnGroups(siteID: sampleSiteID, activeGroupIDs: [123, 1234]) - - // Then - let activeGroups = storage.loadAddOnGroups(siteID: sampleSiteID) - XCTAssertEqual(activeGroups, initialGroups.dropLast()) - } - func test_deleteStalePlugins_deletes_plugins_not_included_in_installedPluginNames() throws { // Given let plugin1 = createPlugin(name: "AAA") diff --git a/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift b/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift index dbab87bc5c5..d627721d55e 100644 --- a/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift +++ b/Storage/StorageTests/Tools/StorageTypeExtensionsTests.swift @@ -189,6 +189,27 @@ final class StorageTypeExtensionsTests: XCTestCase { XCTAssertEqual(customer, storedCustomer) } + func test_loadCustomers_by_siteID_and_customerIDs() { + // Given + let customer1 = storage.insertNewObject(ofType: Customer.self) + customer1.siteID = sampleSiteID + customer1.customerID = 1 + + let customer2 = storage.insertNewObject(ofType: Customer.self) + customer2.siteID = sampleSiteID + customer2.customerID = 2 + + let customer3 = storage.insertNewObject(ofType: Customer.self) + customer3.siteID = sampleSiteID + customer3.customerID = 3 + + // When + let results = storage.loadCustomers(siteID: sampleSiteID, matching: [1, 3]) + + // Then + XCTAssertEqual(Set(results), Set([customer1, customer3])) + } + func test_loadCustomerSearchResult_by_siteID_and_keyword() throws { // Given let keyword: String = "some keyword" @@ -203,6 +224,27 @@ final class StorageTypeExtensionsTests: XCTestCase { XCTAssertEqual(customerSearchResult, storedCustomerSearchResult) } + func test_loadWCAnalyticsCustomers_by_siteID_and_customerIDs() { + // Given + let customer1 = storage.insertNewObject(ofType: WCAnalyticsCustomer.self) + customer1.siteID = sampleSiteID + customer1.customerID = 1 + + let customer2 = storage.insertNewObject(ofType: WCAnalyticsCustomer.self) + customer2.siteID = sampleSiteID + customer2.customerID = 2 + + let customer3 = storage.insertNewObject(ofType: WCAnalyticsCustomer.self) + customer3.siteID = sampleSiteID + customer3.customerID = 3 + + // When + let results = storage.loadWCAnalyticsCustomers(siteID: sampleSiteID, matching: [1, 3]) + + // Then + XCTAssertEqual(Set(results), Set([customer1, customer3])) + } + func test_loadOrderFeeLine_by_siteID_feeID() throws { // Given let feeID: Int64 = 123 diff --git a/TestKit/Package.resolved b/TestKit/Package.resolved new file mode 100644 index 00000000000..d298bc6b326 --- /dev/null +++ b/TestKit/Package.resolved @@ -0,0 +1,43 @@ +{ + "object": { + "pins": [ + { + "package": "CwlCatchException", + "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", + "state": { + "branch": null, + "revision": "07b2ba21d361c223e25e3c1e924288742923f08c", + "version": "2.2.1" + } + }, + { + "package": "CwlPreconditionTesting", + "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state": { + "branch": null, + "revision": "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version": "2.2.2" + } + }, + { + "package": "Difference", + "repositoryURL": "https://github.com/krzysztofzablocki/Difference.git", + "state": { + "branch": "master", + "revision": "7eb73c5d28c87dd6c4bac805aa28f757648aa92c", + "version": null + } + }, + { + "package": "Nimble", + "repositoryURL": "https://github.com/Quick/Nimble.git", + "state": { + "branch": null, + "revision": "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", + "version": "13.7.1" + } + } + ] + }, + "version": 1 +} diff --git a/TestKit/Package.swift b/TestKit/Package.swift index a324d429cd6..529047992af 100644 --- a/TestKit/Package.swift +++ b/TestKit/Package.swift @@ -11,12 +11,13 @@ let package = Package( targets: ["TestKit"]), ], dependencies: [ - .package(name: "Difference", url: "https://github.com/krzysztofzablocki/Difference.git", .branch("master")) + .package(name: "Difference", url: "https://github.com/krzysztofzablocki/Difference.git", .branch("master")), + .package(url: "https://github.com/Quick/Nimble.git", from: "13.0.0"), ], targets: [ .target( name: "TestKit", - dependencies: ["Difference"]), + dependencies: ["Difference", "Nimble"]), .testTarget( name: "TestKitTests", dependencies: ["TestKit"]), diff --git a/TestKit/Sources/TestKit/XCTestCase+Wait.swift b/TestKit/Sources/TestKit/XCTestCase+Wait.swift index 2e8217efded..f9a6e4c5641 100644 --- a/TestKit/Sources/TestKit/XCTestCase+Wait.swift +++ b/TestKit/Sources/TestKit/XCTestCase+Wait.swift @@ -1,5 +1,6 @@ import XCTest +import Nimble extension XCTestCase { /// Creates an XCTestExpectation and waits for `block` to call `fulfill()`. @@ -24,7 +25,7 @@ extension XCTestCase { wait(for: [exp], timeout: timeout) } - /// Creates an `XCTestExpectation` and waits until `condition` returns `true`. + /// Waits until `condition` returns `true`. /// /// Example usage: /// @@ -36,23 +37,37 @@ extension XCTestCase { /// } /// ``` /// - public func waitUntil(file: StaticString = #file, + @available(*, noasync, message: "Use await until(expression:) instead!") + public func waitUntil(fileID: String = #fileID, + file: FileString = #filePath, line: UInt = #line, + column: UInt = #column, timeout: TimeInterval = 5.0, - condition: @escaping (() -> Bool)) { - let predicate = NSPredicate { _, _ -> Bool in - return condition() - } - - let exp = expectation(for: predicate, evaluatedWith: nil) + _ expression: @escaping () throws -> Bool) { + _ = expect(fileID: fileID, file: file, line: line, column: column, expression) + .toEventually(beTrue(), timeout: .seconds(Int(timeout))) + } - let result = XCTWaiter.wait(for: [exp], timeout: timeout) - switch result { - case .timedOut: - XCTFail("Timed out waiting for condition to return `true`.", file: file, line: line) - default: - break - } + /// Waits until `condition` returns `true` in async conext. + /// + /// Example usage: + /// + /// ``` + /// var valueThatIsUpdatedAsynchronously: Int = 0 + /// + /// await until { + /// valueThatIsUpdatedAsynchronously > 5 + /// } + /// ``` + /// + public func until(fileID: String = #fileID, + file: FileString = #filePath, + line: UInt = #line, + column: UInt = #column, + timeout: TimeInterval = 5.0, + _ expression: @escaping () throws -> Bool) async { + _ = await expect(fileID: fileID, file: file, line: line, column: column, expression) + .toEventually(beTrue(), timeout: .seconds(Int(timeout))) } /// Waits until a value is provided by a promise (block) and returns that value. diff --git a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved index 49baf846987..81cd7de1d7a 100644 --- a/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WooCommerce.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -28,6 +28,24 @@ "version": "1.0.1" } }, + { + "package": "CwlCatchException", + "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", + "state": { + "branch": null, + "revision": "07b2ba21d361c223e25e3c1e924288742923f08c", + "version": "2.2.1" + } + }, + { + "package": "CwlPreconditionTesting", + "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state": { + "branch": null, + "revision": "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version": "2.2.2" + } + }, { "package": "Difference", "repositoryURL": "https://github.com/krzysztofzablocki/Difference.git", @@ -55,13 +73,22 @@ "version": "1.1.1" } }, + { + "package": "Nimble", + "repositoryURL": "https://github.com/Quick/Nimble.git", + "state": { + "branch": null, + "revision": "7795df4fff1a9cd231fe4867ae54f4dc5f5734f9", + "version": "13.7.1" + } + }, { "package": "ScreenObject", "repositoryURL": "https://github.com/Automattic/ScreenObject", "state": { "branch": null, - "revision": "328db56c62aab91440ec5e07cc9f7eef6e26a26e", - "version": "0.2.3" + "revision": "7aa2f067f24c8083ba3cfede002ea79e5bf43903", + "version": "0.2.4" } }, { diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+Blaze.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+Blaze.swift index 23f63547e78..53c5841ad55 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+Blaze.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+Blaze.swift @@ -101,6 +101,11 @@ extension WooAnalyticsEvent { return WooAnalyticsEvent(statName: .blazeCreationConfirmDetailsTapped, properties: properties) } + + /// Tracked when the /suggestions request fails. + static func suggestionLoadingFailed(error: Error) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .blazeSuggestionsLoadingFailed, properties: [:], error: error) + } } enum EditAd { diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift index 508aad44a17..c7d18bf16d4 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift @@ -1819,6 +1819,7 @@ extension WooAnalyticsEvent { case paymentWaitingForInput = "payment_waiting_for_input" case connectionError = "connection_error" case readerSoftwareUpdate = "reader_software_update" + case locationPermissionDenied = "location_permission_denied" case other = "unknown" } @@ -2288,6 +2289,7 @@ extension WooAnalyticsEvent { enum ReceiptSource: String { case local case backend + case api } enum LearnMoreLinkSource { @@ -2316,6 +2318,33 @@ extension WooAnalyticsEvent { static func learnMoreTapped(source: LearnMoreLinkSource) -> WooAnalyticsEvent { WooAnalyticsEvent(statName: .inPersonPaymentsLearnMoreTapped, properties: ["source": source.trackingValue]) } + + static func tapToPayTermsOfServiceAccepted(gatewayID: String?, + countryCode: CountryCode) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .tapToPayTermsOfServiceAccepted, + properties: [ + Keys.countryCode: countryCode.rawValue, + Keys.gatewayID: safeGatewayID(for: gatewayID) + ]) + } + + static func cardReaderLocationPermissionPreAlertShown(gatewayID: String?, + countryCode: CountryCode) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .cardReaderLocationPermissionPreAlertShown, + properties: [ + Keys.countryCode: countryCode.rawValue, + Keys.gatewayID: safeGatewayID(for: gatewayID) + ]) + } + + static func cardReaderLocationPermissionRequiredShown(gatewayID: String?, + countryCode: CountryCode) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .cardReaderLocationPermissionRequiredShown, + properties: [ + Keys.countryCode: countryCode.rawValue, + Keys.gatewayID: safeGatewayID(for: gatewayID) + ]) + } } } @@ -2498,6 +2527,7 @@ extension WooAnalyticsEvent { case isJetpackInstalled = "is_jetpack_installed" case isJetpackActive = "is_jetpack_active" case isJetpackConnected = "is_jetpack_connected" + case hiddenSiteCount = "hidden_site_count" } /// Tracks when the result for site discovery is returned @@ -2518,6 +2548,37 @@ extension WooAnalyticsEvent { static func newToWooTapped() -> WooAnalyticsEvent { WooAnalyticsEvent(statName: .sitePickerNewToWooTapped, properties: [:]) } + + /// Tracks when the Edit button is shown on the top right. + /// + static func editButtonShown() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .sitePickerEditButtonShown, properties: [:]) + } + + /// Tracks when the user taps the Edit button on the top right. + /// + static func editButtonTapped() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .sitePickerEditButtonTapped, properties: [:]) + } + + /// Tracks when the user taps the Save button on the edit store list screen + /// + static func listSaveButtonTapped(hiddenSiteCount: Int) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .sitePickerListSaveButtonTapped, + properties: [Key.hiddenSiteCount.rawValue: hiddenSiteCount]) + } + + /// Tracks when saving is successful + /// + static func listEditSavingSuccess() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .sitePickerListSavingSuccess, properties: [:]) + } + + /// Tracks when saving fails + /// + static func listEditSavingFailure(error: Error) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .sitePickerListSavingFailure, properties: [:], error: error) + } } } diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift index 1e2bfffd1d8..5a211ef86e0 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsStat.swift @@ -236,6 +236,7 @@ enum WooAnalyticsStat: String { case blazeCampaignCreationSuccess = "blaze_campaign_creation_success" case blazeCampaignCreationFailed = "blaze_campaign_creation_failed" case blazeCampaignCreationFeedback = "blaze_campaign_creation_feedback" + case blazeSuggestionsLoadingFailed = "blaze_suggestions_loading_failed" // MARK: Store Onboarding Events // @@ -258,6 +259,11 @@ enum WooAnalyticsStat: String { case sitePickerNewToWooTapped = "site_picker_new_to_woo_tapped" case sitePickerAddStoreTapped = "site_picker_add_a_store_tapped" case sitePickerConnectExistingStoreTapped = "site_picker_connect_existing_store_tapped" + case sitePickerEditButtonShown = "site_picker_edit_button_shown" + case sitePickerEditButtonTapped = "site_picker_edit_button_tapped" + case sitePickerListSaveButtonTapped = "site_picker_list_save_button_tapped" + case sitePickerListSavingSuccess = "site_picker_list_saving_success" + case sitePickerListSavingFailure = "site_picker_list_saving_failure" // MARK: Site creation // @@ -346,15 +352,14 @@ enum WooAnalyticsStat: String { case cardReaderSelectTypeShown = "card_present_select_reader_type_shown" case cardReaderSelectTypeBuiltInTapped = "card_present_select_reader_type_built_in_tapped" case cardReaderSelectTypeBluetoothTapped = "card_present_select_reader_type_bluetooth_tapped" - case cardReaderDiscoveryTapped = "card_reader_discovery_tapped" case cardReaderDiscoveryFailed = "card_reader_discovery_failed" - case cardReaderDiscoveredReader = "card_reader_discovery_reader_discovered" - case cardReaderConnectionTapped = "card_reader_connection_tapped" case cardReaderConnectionFailed = "card_reader_connection_failed" case cardReaderConnectionSuccess = "card_reader_connection_success" case cardReaderDisconnectTapped = "card_reader_disconnect_tapped" case manageCardReadersBuiltInReaderAutoDisconnect = "manage_card_readers_automatic_disconnect_built_in_reader" case cardReaderAutomaticDisconnect = "card_reader_automatic_disconnect" + case cardReaderLocationPermissionPreAlertShown = "card_reader_location_permission_pre_alert_shown" + case cardReaderLocationPermissionRequiredShown = "card_reader_location_permission_required_shown" // MARK: Card Reader Software Update Events // @@ -384,6 +389,13 @@ enum WooAnalyticsStat: String { case aboutTapToPayOrderCardReaderTapped = "about_tap_to_pay_order_card_reader_tapped" case tapToPayAutoRefundSuccess = "card_present_tap_to_pay_test_payment_refund_success" case tapToPayAutoRefundFailed = "card_present_tap_to_pay_test_payment_refund_failed" + case tapToPayAwarenessShown = "tap_to_pay_awareness_shown" + case tapToPayTermsOfServiceAccepted = "tap_to_pay_terms_of_service_accepted" + + // MARK: Tap to Pay Education + case tapToPayEducationShown = "tap_to_pay_education_shown" + case tapToPayEducationDone = "tap_to_pay_education_done" + case tapToPayEducationSkipped = "tap_to_pay_education_skipped" // MARK: Cash on Delivery Enable events case enableCashOnDeliverySuccess = "enable_cash_on_delivery_success" diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index d55c07d0548..c7fc86306de 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -263,6 +263,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func applicationDidReceiveMemoryWarning(_ application: UIApplication) { let size = os_proc_available_memory() DDLogDebug("Received memory warning: Available memory - \(size)") + ServiceLocator.imageService.clearMemoryCache() } } diff --git a/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListView.swift b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListView.swift new file mode 100644 index 00000000000..26bbc7d419d --- /dev/null +++ b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +/// Hosting controller for `EditStoreListView` +/// +final class EditStoreListViewController: UIHostingController { + init(viewModel: EditStoreListViewModel) { + super.init(rootView: EditStoreListView(viewModel: viewModel)) + rootView.onDismiss = { [weak self] in + self?.dismiss(animated: true) + } + } + + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// View to edit the items to be displayed on the store picker +/// +struct EditStoreListView: View { + @ObservedObject var viewModel: EditStoreListViewModel + + /// To be set externally in the hosting controller + var onDismiss: () -> Void = {} + + init(viewModel: EditStoreListViewModel) { + self.viewModel = viewModel + } + + var body: some View { + NavigationStack { + List { + if let site = viewModel.currentlySelectedSite { + Section { + VStack(alignment: .leading) { + Text(site.name) + .bodyStyle() + Text(site.url) + .footnoteStyle() + } + .multilineTextAlignment(.leading) + } header: { + Text(Localization.currentStoreHeader) + } footer: { + Text(Localization.currentStoreFooter) + } + } + + Section { + ForEach(viewModel.availableSites, id: \.siteID) { item in + SelectableItemRow(title: item.name, + subtitle: item.url, + selected: viewModel.isSelected(item), + displayMode: .compact, + verticalSpacing: 0, + selectionStyle: .checkcircle) + .listRowInsets(.zero) + .onTapGesture { + viewModel.toggleSelection(item) + } + .disabled(viewModel.isLastSelected(item)) + } + } header: { + Text(Localization.otherStoresHeader) + } footer: { + Text(Localization.otherStoresFooter) + } + } + .listStyle(.grouped) + .navigationTitle(Localization.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Localization.cancelButton) { + onDismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + if viewModel.isUpdatingNotificationSettings { + ProgressView() + .progressViewStyle(.circular) + } else { + Button(Localization.saveButton) { + Task { + await viewModel.saveChanges() + } + } + .disabled(viewModel.hasChanges == false) + } + } + } + .alert(Localization.errorUpdatingNotificationSettings, isPresented: $viewModel.shouldShowErrorAlert, actions: { + Button(Localization.cancelButton) {} + Button(Localization.retryButton) { + Task { + await viewModel.saveChanges() + } + } + }) + } + } +} + +private extension EditStoreListView { + enum Localization { + static let cancelButton = NSLocalizedString( + "editStoreListView.cancelButton", + value: "Cancel", + comment: "Button to dismiss the Edit Store List view" + ) + static let saveButton = NSLocalizedString( + "editStoreListView.saveButton", + value: "Save", + comment: "Button to save changes in the Edit Store List view" + ) + static let title = NSLocalizedString( + "editStoreListView.title", + value: "Visible Stores", + comment: "Title of the Edit Store List view" + ) + static let currentStoreHeader = NSLocalizedString( + "editStoreListView.currentStoreHeader", + value: "Current store", + comment: "Header of the Current Store section of the the Edit Store List view" + ) + static let currentStoreFooter = NSLocalizedString( + "editStoreListView.currentStoreFooter", + value: "Please switch to another store before hiding this store", + comment: "Footer of the Current Store section of the the Edit Store List view" + ) + static let otherStoresHeader = NSLocalizedString( + "editStoreListView.otherStoresHeader", + value: "Other stores", + comment: "Header of the Other Stores section on the Edit Store List view" + ) + static let otherStoresFooter = NSLocalizedString( + "editStoreListView.otherStoresFooter", + value: "Stores that are not selected will be excluded from the store picker", + comment: "Footer of the Other Stores section on the Edit Store List view" + ) + static let retryButton = NSLocalizedString( + "editStoreListView.retryButton", + value: "Retry", + comment: "Button to retry saving changes in the Edit Store List view" + ) + static let errorUpdatingNotificationSettings = NSLocalizedString( + "editStoreListView.errorUpdatingNotificationSettings", + value: "There was an error when updating notification settings. Please try again.", + comment: "Error message when updating notification settings fails when saving the store list for the store picker" + ) + } +} diff --git a/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListViewModel.swift b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListViewModel.swift new file mode 100644 index 00000000000..e1dc548cd74 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/EditStoreListViewModel.swift @@ -0,0 +1,115 @@ +import Foundation +import Yosemite +import protocol WooFoundation.Analytics + +/// View model for `EditStoreListView` +/// +final class EditStoreListViewModel: ObservableObject { + /// All available sites to be displayed on the store picker + let availableSites: [Site] + + let currentlySelectedSite: Site? + + /// Sites selected to be displayed on the store picker + @Published var selectedSites: Set + + @Published private(set) var isUpdatingNotificationSettings = false + + @Published var shouldShowErrorAlert = false + + var hasChanges: Bool { + selectedSites != Set(originalSelectedSites) + } + + private let originalSelectedSites: [Site] + private let stores: StoresManager + private let pushNotificationManager: PushNotesManager + private let userDefaults: UserDefaults + private let analytics: Analytics + private let onCompletion: () -> Void + + init(availableSites: [Site], + displayedSites: [Site], + currentlySelectedSite: Site?, + stores: StoresManager = ServiceLocator.stores, + pushNotificationManager: PushNotesManager = ServiceLocator.pushNotesManager, + userDefaults: UserDefaults = .standard, + analytics: Analytics = ServiceLocator.analytics, + onCompletion: @escaping () -> Void) { + self.availableSites = availableSites.filter { $0.siteID != currentlySelectedSite?.siteID } + self.currentlySelectedSite = currentlySelectedSite + self.originalSelectedSites = displayedSites + self.selectedSites = Set(displayedSites) + self.stores = stores + self.pushNotificationManager = pushNotificationManager + self.userDefaults = userDefaults + self.analytics = analytics + self.onCompletion = onCompletion + } + + @MainActor + func saveChanges() async { + let hiddenSites = Set(availableSites).subtracting(selectedSites) + let hiddenSiteIDs = Array(hiddenSites).map { $0.siteID } + let displayedSiteIDs = Array(selectedSites).map { $0.siteID } + + analytics.track(event: .SitePicker.listSaveButtonTapped(hiddenSiteCount: hiddenSiteIDs.count)) + shouldShowErrorAlert = false + isUpdatingNotificationSettings = true + do { + try await updateNotificationSettings(displayedSiteIDs: displayedSiteIDs, hiddenSiteIDs: hiddenSiteIDs) + userDefaults.saveHiddenStoreIDs(hiddenSiteIDs) + analytics.track(event: .SitePicker.listEditSavingSuccess()) + onCompletion() + } catch { + shouldShowErrorAlert = true + analytics.track(event: .SitePicker.listEditSavingFailure(error: error)) + } + isUpdatingNotificationSettings = false + } +} + +// MARK: - Helper methods for selection +extension EditStoreListViewModel { + + /// Checks if the given site is selected. + func isSelected(_ site: Site) -> Bool { + selectedSites.contains(site) + } + + /// Checks if the given site is the last selected item. + /// This is used to disable that row, so it can't be deselected. + func isLastSelected(_ site: Site) -> Bool { + isSelected(site) && selectedSites.count == 1 + } + + /// Selects or deselects the given site. + func toggleSelection(_ site: Site) { + if selectedSites.contains(site) { + selectedSites.remove(site) + } else { + selectedSites.insert(site) + } + } +} + +private extension EditStoreListViewModel { + @MainActor + func updateNotificationSettings(displayedSiteIDs: [Int64], + hiddenSiteIDs: [Int64]) async throws { + guard let deviceID = pushNotificationManager.deviceID, + let intDeviceID = Int64(deviceID) else { + /// skip updating notification settings if no device ID is found + return + } + + let settings = NotificationSettings(deviceID: intDeviceID, + enabledSites: displayedSiteIDs, + disabledSites: hiddenSiteIDs) + try await withCheckedThrowingContinuation { continuation in + stores.dispatch(AccountAction.updateNotificationSettings(notificationSettings: settings, onCompletion: { result in + continuation.resume(with: result) + })) + } + } +} diff --git a/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/UserDefaults+EditStoreList.swift b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/UserDefaults+EditStoreList.swift new file mode 100644 index 00000000000..c6d7810c6d5 --- /dev/null +++ b/WooCommerce/Classes/Authentication/Epilogue/EditStoreList/UserDefaults+EditStoreList.swift @@ -0,0 +1,15 @@ +import Foundation + +// MARK: - Edit store list helpers +// +extension UserDefaults { + @objc dynamic var hiddenStoreIDs: [Int64] { + array(forKey: Key.hiddenStoreIDs.rawValue) as? [Int64] ?? [] + } + + /// Saves objective ID for future Blaze campaigns + /// + func saveHiddenStoreIDs(_ ids: [Int64]) { + self[.hiddenStoreIDs] = ids + } +} diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift index b8628c817fd..8ae0f6354e3 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift @@ -68,7 +68,7 @@ final class StorePickerViewController: UIViewController { /// private let viewModel: StorePickerViewModel - private var stateSubscription: AnyCancellable? + private var subscriptions: Set = [] private lazy var requirementsChecker = RequirementsChecker() @@ -293,12 +293,26 @@ private extension StorePickerViewController { } func observeStateChange() { - stateSubscription = viewModel.$state.sink { [weak self] _ in + viewModel.$state.sink { [weak self] _ in guard let self = self else { return } self.preselectStoreIfPossible() self.reloadInterface() self.updateFooterViewIfNeeded() - } + }.store(in: &subscriptions) + + viewModel.$shouldEnableHidingStores + .removeDuplicates() + .sink { [weak self] shouldEnable in + guard let self else { return } + if shouldEnable { + ServiceLocator.analytics.track(event: .SitePicker.editButtonShown()) + } + navigationItem.rightBarButtonItem = !shouldEnable ? nil : UIBarButtonItem(title: Localization.editListButton, + style: .plain, + target: self, + action: #selector(editList)) + } + .store(in: &subscriptions) } func backgroundColor() -> UIColor { @@ -647,6 +661,21 @@ private extension StorePickerViewController { @IBAction func secondaryActionWasPressed() { restartAuthentication() } + + @objc func editList() { + ServiceLocator.analytics.track(event: .SitePicker.editButtonTapped()) + let editViewModel = EditStoreListViewModel(availableSites: viewModel.allFetchedSites, + displayedSites: viewModel.displayedStores, + currentlySelectedSite: currentlySelectedSite, + onCompletion: { [weak self] in + guard let self else { return } + viewModel.updateDisplayedStores() + tableView.reloadData() + presentedViewController?.dismiss(animated: true) + }) + let viewController = EditStoreListViewController(viewModel: editViewModel) + present(viewController, animated: true) + } } @@ -816,6 +845,12 @@ private extension StorePickerViewController { static let addStoreButton = NSLocalizedString("storePickerViewController.addStoreButton", value: "Connect existing store", comment: "Button title on the store picker for store connection") + static let editListButton = NSLocalizedString( + "storePicker.editList", + value: "Edit", + comment: "Button to edit the items to be displayed on the store picker" + ) + enum ActionMenu { static let logOut = NSLocalizedString("Log out", comment: "Button to log out from the current account from the store picker") diff --git a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift index e652bb1882d..e2c691803c8 100644 --- a/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift +++ b/WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Experiments import Yosemite import protocol Storage.StorageManagerType import protocol WooFoundation.Analytics @@ -10,6 +11,14 @@ final class StorePickerViewModel { /// @Published private(set) var state: StorePickerState = .empty + @Published private(set) var shouldEnableHidingStores = false + + var allFetchedSites: [Site] { + resultsController.fetchedObjects + } + + private(set) var displayedStores: [Site] = [] + /// ResultsController: Loads Sites from the Storage Layer. /// private lazy var resultsController: ResultsController = { @@ -25,16 +34,22 @@ final class StorePickerViewModel { private let storageManager: StorageManagerType private let stores: StoresManager + private let userDefaults: UserDefaults private let analytics: Analytics private let roleEligibilityUseCase: RoleEligibilityUseCase + private let featureFlagService: FeatureFlagService init(configuration: StorePickerConfiguration, stores: StoresManager = ServiceLocator.stores, storageManager: StorageManagerType = ServiceLocator.storageManager, + userDefaults: UserDefaults = .standard, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, analytics: Analytics = ServiceLocator.analytics) { self.configuration = configuration self.stores = stores self.storageManager = storageManager + self.userDefaults = userDefaults + self.featureFlagService = featureFlagService self.analytics = analytics self.roleEligibilityUseCase = RoleEligibilityUseCase(stores: stores) } @@ -77,19 +92,37 @@ final class StorePickerViewModel { func checkEligibility(for storeID: Int64, completion: @escaping (Result) -> Void) { roleEligibilityUseCase.checkEligibility(for: storeID, completion: completion) } + + func updateDisplayedStores() { + let hiddenStoreIDs = userDefaults.hiddenStoreIDs + displayedStores = allFetchedSites.filter { hiddenStoreIDs.contains($0.siteID) == false } + } } // MARK: - Private helpers private extension StorePickerViewModel { + func refetchSitesAndUpdateState() { do { try resultsController.performFetch() - state = StorePickerState(sites: resultsController.fetchedObjects) + updateDisplayedStores() + checkIfHidingStoresShouldBeEnabled() + state = StorePickerState(sites: allFetchedSites) } catch { DDLogError("⛔️ Unable to re-fetch sites and update state: \(error)") } } + func checkIfHidingStoresShouldBeEnabled() { + shouldEnableHidingStores = { + guard featureFlagService.isFeatureFlagEnabled(.hideSitesInStorePicker), + configuration == .switchingStores else { + return false + } + return allFetchedSites.filter { $0.isWooCommerceActive }.count > 1 + }() + } + func synchronizeSites(selectedSiteID: Int64?, onCompletion: @escaping (Result) -> Void) { let syncStartTime = Date() let action = AccountAction @@ -150,9 +183,16 @@ extension StorePickerViewModel { // The value is returned as either 0 or 1 in String, // so the trick is to convert it to NSString and get the `boolValue`. let isWooCommerceActive = (rawStatus as NSString).boolValue - if isWooCommerceActive { - return multipleStoresAvailable ? Localization.pickStore : Localization.connectedStore - } else { + switch (isWooCommerceActive, multipleStoresAvailable) { + case (true, true): + let hiddenStoreCount = userDefaults.hiddenStoreIDs.count + if hiddenStoreCount > 0, shouldEnableHidingStores { + return String(format: Localization.pickStoreWithHiddenStoreCount, hiddenStoreCount) + } + return Localization.pickStore + case (true, false): + return Localization.connectedStore + case (false, _): return Localization.otherSites } } @@ -163,6 +203,9 @@ extension StorePickerViewModel { guard resultsController.numberOfObjects > 0 else { return 1 } + if shouldEnableHidingStores { + return displayedStores.count + } return resultsController.sections[safe: sectionIndex]?.objects.count ?? 0 } @@ -172,6 +215,9 @@ extension StorePickerViewModel { guard resultsController.numberOfObjects > 0 else { return nil } + if shouldEnableHidingStores { + return displayedStores[safe: indexPath.row] + } return resultsController.safeObject(at: indexPath) } @@ -212,6 +258,13 @@ private extension StorePickerViewModel { "Other Sites", comment: "Store Picker's Section Title: Displayed when there are sites without WooCommerce" ) + static let pickStoreWithHiddenStoreCount = NSLocalizedString( + "storePickerViewModel.pickStoreWithHiddenStoreCount", + value: "Pick Store to Connect (%1$d hidden)", + comment: "Store Picker's Section Title: Displayed whenever there are multiple Stores. " + + "The content inside the bracket indicates the number of stores hidden from the store picker. " + + "The placeholder is a number. Reads as: 'Pick Store to Connect (3 hidden)'" + ) } } diff --git a/WooCommerce/Classes/Blaze/BlazeLocalNotificationScheduler.swift b/WooCommerce/Classes/Blaze/BlazeLocalNotificationScheduler.swift index 53ac082ab8c..e4ab1d1445f 100644 --- a/WooCommerce/Classes/Blaze/BlazeLocalNotificationScheduler.swift +++ b/WooCommerce/Classes/Blaze/BlazeLocalNotificationScheduler.swift @@ -159,13 +159,7 @@ private extension DefaultBlazeLocalNotificationScheduler { self?.scheduleNoCampaignReminderIfNeeded() } blazeCampaignResultsController.onDidResetContent = { [weak self] in - /// Upon logging out, `CoreDataManager` resets the storage and triggers the reset notification - /// causing refetching data. Checking the authentication state helps avoiding reloading data - /// in the unauthenticated state. - guard let self, stores.isAuthenticated else { - return - } - scheduleNoCampaignReminderIfNeeded() + self?.scheduleNoCampaignReminderIfNeeded() } do { @@ -177,6 +171,12 @@ private extension DefaultBlazeLocalNotificationScheduler { } func scheduleNoCampaignReminderIfNeeded() { + /// Upon logging out, `CoreDataManager` clears the storage triggering data change. + /// Checking the authentication state helps avoiding reloading data + /// in the unauthenticated state. + guard stores.isAuthenticated else { + return + } guard !userDefaults.blazeNoCampaignReminderOpened() else { DDLogDebug("Blaze: User interacted with a previously scheduled no campaign local notification. Don't schedule again.") return diff --git a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift index 90c12ccaf13..c6d97cd86aa 100644 --- a/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift +++ b/WooCommerce/Classes/Copiable/Models+Copiable.generated.swift @@ -50,6 +50,36 @@ extension WooCommerce.AggregateOrderItem { } } +extension WooCommerce.ItemsStackState { + func copy( + root: CopiableProp = .copy, + itemStates: CopiableProp<[POSItem: ItemListState]> = .copy + ) -> WooCommerce.ItemsStackState { + let root = root ?? self.root + let itemStates = itemStates ?? self.itemStates + + return WooCommerce.ItemsStackState( + root: root, + itemStates: itemStates + ) + } +} + +extension WooCommerce.ItemsViewState { + func copy( + containerState: CopiableProp = .copy, + itemsStack: CopiableProp = .copy + ) -> WooCommerce.ItemsViewState { + let containerState = containerState ?? self.containerState + let itemsStack = itemsStack ?? self.itemsStack + + return WooCommerce.ItemsViewState( + containerState: containerState, + itemsStack: itemsStack + ) + } +} + extension WooCommerce.ShippingLabelSelectedRate { func copy( packageID: CopiableProp = .copy, diff --git a/WooCommerce/Classes/Destinations/PaymentsMenuDestination.swift b/WooCommerce/Classes/Destinations/PaymentsMenuDestination.swift index 12fda65c6ea..312ec0d53b3 100644 --- a/WooCommerce/Classes/Destinations/PaymentsMenuDestination.swift +++ b/WooCommerce/Classes/Destinations/PaymentsMenuDestination.swift @@ -3,4 +3,5 @@ import Foundation enum PaymentsMenuDestination: DeepLinkDestinationProtocol { case collectPayment case tapToPay + case aboutTapToPay } diff --git a/WooCommerce/Classes/Extensions/UIImage+Woo.swift b/WooCommerce/Classes/Extensions/UIImage+Woo.swift index 50564115380..0db0222bb4d 100644 --- a/WooCommerce/Classes/Extensions/UIImage+Woo.swift +++ b/WooCommerce/Classes/Extensions/UIImage+Woo.swift @@ -1576,6 +1576,10 @@ extension UIImage { static var magnifyingGlassNotFound: UIImage { UIImage(imageLiteralResourceName: "magnifying-glass-not-found") } + + static var cardReaderLocationImage: UIImage { + UIImage(named: "card-reader-location-permission")! + } } private extension UIImage { diff --git a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift index 5c7617eef67..57d24b1f494 100644 --- a/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift +++ b/WooCommerce/Classes/Extensions/UserDefaults+Woo.swift @@ -49,10 +49,6 @@ extension UserDefaults { // Theme installation case themesPendingInstall - // Store Creation - case siteIDPendingStoreSwitch - case expectedStoreNamePendingStoreSwitch - // Watch case watchDependencies @@ -69,6 +65,13 @@ extension UserDefaults { // Whether the site is suspended on WordPress.com and can't be connected using Jetpack // case wpcomSiteSuspended + + // Tap to Pay awareness moment + case tapToPayAwarenessMomentPresented + case tapToPayAwarenessMomentFirstLaunchCompleted + + // Hide stores from store picker + case hiddenStoreIDs } } diff --git a/WooCommerce/Classes/GoogleAds/GoogleAdsCampaignCoordinator.swift b/WooCommerce/Classes/GoogleAds/GoogleAdsCampaignCoordinator.swift index 1d155e32fde..06c42e62844 100644 --- a/WooCommerce/Classes/GoogleAds/GoogleAdsCampaignCoordinator.swift +++ b/WooCommerce/Classes/GoogleAds/GoogleAdsCampaignCoordinator.swift @@ -11,8 +11,12 @@ final class GoogleAdsCampaignCoordinator: NSObject, Coordinator { private let siteAdminURL: String private let source: WooAnalyticsEvent.GoogleAds.Source - private let shouldStartCampaignCreation: Bool - private let shouldAuthenticateAdminPage: Bool + // set internal for testability + let shouldStartCampaignCreation: Bool + + // set internal for testability + let shouldAuthenticateAdminPage: Bool + private var bottomSheetPresenter: BottomSheetPresenter? private let analytics: Analytics diff --git a/WooCommerce/Classes/Notifications/PushNotificationsManager.swift b/WooCommerce/Classes/Notifications/PushNotificationsManager.swift index 4ceda2980eb..8fe0a509010 100644 --- a/WooCommerce/Classes/Notifications/PushNotificationsManager.swift +++ b/WooCommerce/Classes/Notifications/PushNotificationsManager.swift @@ -93,7 +93,7 @@ final class PushNotificationsManager: PushNotesManager { /// WordPress.com Device Identifier /// - private var deviceID: String? { + private(set) var deviceID: String? { get { return configuration.defaults.object(forKey: .deviceID) } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBluetoothReaderConnectionAlertsProvider.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBluetoothReaderConnectionAlertsProvider.swift index 7f7a0804516..d55a72ab4bc 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBluetoothReaderConnectionAlertsProvider.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBluetoothReaderConnectionAlertsProvider.swift @@ -102,4 +102,13 @@ struct CardPresentPaymentBluetoothReaderConnectionAlertsProvider: BluetoothReade .updateFailedLowBattery(batteryLevel: batteryLevel, cancelUpdate: close) } + + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> CardPresentPaymentEventDetails { + .locationRequestPreAlert(requestPermission: requestPermission) + } + + func locationRequired(dismiss: @escaping () -> Void, + skip: @escaping () -> Void) -> CardPresentPaymentEventDetails { + .locationRequired(dismiss: dismiss, skip: skip) + } } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBuiltInReaderConnectionAlertsProvider.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBuiltInReaderConnectionAlertsProvider.swift index 991dae84e3b..78db1ffa2b0 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBuiltInReaderConnectionAlertsProvider.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentBuiltInReaderConnectionAlertsProvider.swift @@ -77,4 +77,13 @@ struct CardPresentPaymentBuiltInReaderConnectionAlertsProvider: CardReaderConnec bluetooth: bluetooth, endSearch: cancel) } + + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> CardPresentPaymentEventDetails { + .locationRequestPreAlert(requestPermission: requestPermission) + } + + func locationRequired(dismiss: @escaping () -> Void, + skip: @escaping () -> Void) -> CardPresentPaymentEventDetails { + .locationRequired(dismiss: dismiss, skip: skip) + } } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentCollectOrderPaymentUseCaseAdaptor.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentCollectOrderPaymentUseCaseAdaptor.swift index a50834c0bf7..5b23fc60b53 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentCollectOrderPaymentUseCaseAdaptor.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentCollectOrderPaymentUseCaseAdaptor.swift @@ -155,6 +155,10 @@ private extension CardPresentPaymentCollectOrderPaymentUseCaseAdaptor { cancelPayment(paymentOrchestrator: paymentOrchestrator) case .paymentSuccess(done: let done): done() + case .locationRequestPreAlert: + return + case .locationRequired(let dismiss, _): + dismiss() } } } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentEventDetails.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentEventDetails.swift index 8483d66210d..92985554c57 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentEventDetails.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentEventDetails.swift @@ -53,4 +53,8 @@ enum CardPresentPaymentEventDetails { case displayReaderMessage(message: String) case cancelledOnReader case validatingOrder(cancelPayment: () -> Void) + + case locationRequestPreAlert(requestPermission: () -> Void) + case locationRequired(dismiss: () -> Void, + skip: () -> Void) } diff --git a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsRetryApproach.swift b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsRetryApproach.swift index e2bfd6e6e90..001e697d11b 100644 --- a/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsRetryApproach.swift +++ b/WooCommerce/Classes/POS/Card Present Payments/CardPresentPaymentsRetryApproach.swift @@ -59,7 +59,6 @@ private extension CardReaderServiceUnderlyingError { .bluetoothError, .bluetoothScanTimedOut, .bluetoothConnectionFailedBatteryCriticallyLow, - .bluetoothDenied, .readerSoftwareUpdateFailedBatteryLow, .readerSoftwareUpdateFailedInterrupted, .readerSoftwareUpdateFailed, @@ -74,6 +73,7 @@ private extension CardReaderServiceUnderlyingError { .readerCommunicationError, .bluetoothConnectTimedOut, .bluetoothDisconnected, + .bluetoothDenied, .unsupportedReaderVersion, .connectFailedReaderIsInUse, .unexpectedSDKError, @@ -92,13 +92,46 @@ private extension CardReaderServiceUnderlyingError { .readerNotAccessibleInBackground, .commandNotAllowedDuringCall, .invalidAmount, - .invalidCurrency: + .invalidCurrency, + .cancelFailedAlreadyCompleted, + .nilRefundPaymentMethod, + .invalidRefundParameters, + .invalidDiscoveryConfiguration, + .invalidReaderForUpdate, + .bluetoothConnectionInvalidLocationIdParameter, + .invalidRequiredParameter, + .invalidLocationIdParameter, + .readerSoftwareUpdateFailedExpiredUpdate, + .missingEMVData, + .commandNotAllowed, + .bluetoothPeerRemovedPairingInformation, + .bluetoothAlreadyPairedWithAnotherDevice, + .unknownReaderIpAddress, + .internetConnectTimeOut, + .bluetoothReconnectStarted, + .appleBuiltInReaderAccountDeactivated, + .readerMissingEncryptionKeys, + .unexpectedReaderError, + .commandRequiresCardholderConsent, + .refundFailed, + .cardSwipeNotAvailable, + .interacNotSupportedOffline, + .offlineAndCardExpired, + .offlineTransactionDeclined, + .offlineCollectAndConfirmMismatch, + .onlinePinNotSupportedOffline, + .offlineTestCardInLivemode, + .stripeAPIResponseDecodingError, + .internalNetworkError, + .connectionTokenProviderCompletedWithError, + .connectionTokenProviderTimedOut: return .tryAgain(retryAction: retryAction) case .paymentDeclinedByPaymentProcessorAPI, .paymentDeclinedByCardReader: return .tryAnotherPaymentMethod(retryAction: retryAction) case .alreadyConnectedToReader, .unsupportedSDK, + .featureNotAvailableWithConnectedReader, .commandCancelled, .bluetoothLowEnergyUnsupported, .readerSessionExpired, @@ -110,8 +143,15 @@ private extension CardReaderServiceUnderlyingError { .appleBuiltInReaderInvalidMerchant, .appleBuiltInReaderDeviceBanned, .unsupportedMobileDeviceConfiguration, - .featureNotAvailableWithConnectedReader, - .readerIncompatible: + .readerIncompatible, + .invalidClientSecret, + .featureNotAvailable, + .nilPaymentIntent, + .nilSetupIntent, + .forwardingTestModePaymentInLiveMode, + .forwardingLiveModePaymentInTestMode, + .readerConnectionConfigurationInvalid, + .readerTippingParameterInvalid: return .dontRetry } } diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift index e831a1aeb27..0f53f763a45 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleItemsController.swift @@ -1,95 +1,168 @@ import Foundation import Combine -import protocol Yosemite.POSDisplayableItem +import enum Yosemite.POSItem import protocol Yosemite.PointOfSaleItemServiceProtocol -import enum Yosemite.PointOfSaleProductServiceError +import struct Yosemite.POSVariableParentProduct +import class Yosemite.Store protocol PointOfSaleItemsControllerProtocol { - var itemListStatePublisher: any Publisher { get } + var itemsViewStatePublisher: any Publisher { get } func loadInitialItems() async func loadNextItems() async func reload() async + func loadInitialChildItems(for parent: POSItem) async } class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol { - private(set) var itemListStatePublisher: any Publisher - private var itemListStateSubject: PassthroughSubject = .init() - private var allItems: [POSDisplayableItem] = [] - private var currentPage: Int = Constants.initialPage - private var mightHaveMorePages: Bool = true + var itemsViewStatePublisher: any Publisher { + $itemsViewState.eraseToAnyPublisher() + } + @Published private var itemsViewState: ItemsViewState = + ItemsViewState(containerState: .loading, + itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])) + private let paginationTracker: AsyncPaginationTracker private let itemProvider: PointOfSaleItemServiceProtocol init(itemProvider: PointOfSaleItemServiceProtocol) { self.itemProvider = itemProvider - itemListStatePublisher = itemListStateSubject.eraseToAnyPublisher() + self.paginationTracker = .init() } @MainActor func loadInitialItems() async { - mightHaveMorePages = true - itemListStateSubject.send(.initialLoading) - try? await load(pageNumber: Constants.initialPage) + itemsViewState = .init(containerState: .loading, itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])) + do { + try await paginationTracker.syncFirstPage { [weak self] pageNumber in + guard let self else { return true } + return try await fetchItems(pageNumber: pageNumber) + } + } catch { + itemsViewState = .init(containerState: .error(PointOfSaleErrorState.errorOnLoadingProducts()), + itemsStack: ItemsStackState(root: .loaded([]), + itemStates: [:])) + } } @MainActor func loadNextItems() async { + guard paginationTracker.hasNextPage else { + return + } + let currentItems = itemsViewState.itemsStack.root.items + let currentItemStates = itemsViewState.itemsStack.itemStates + itemsViewState = .init(containerState: .content, itemsStack: ItemsStackState(root: .loading(currentItems), + itemStates: currentItemStates)) do { - guard mightHaveMorePages else { - return + _ = try await paginationTracker.ensureNextPageIsSynced { [weak self] pageNumber in + guard let self else { return true } + return try await fetchItems(pageNumber: pageNumber) } - itemListStateSubject.send(.loading(allItems)) - - let nextPage = currentPage + 1 - try await load(pageNumber: nextPage) - currentPage = nextPage } catch { - // Handle errors without incrementing currentPage. + // TODO: 14694 - Handle error from loading the next page, like showing an error UI at the end or as an overlay. + itemsViewState = .init(containerState: .error(PointOfSaleErrorState.errorOnLoadingProducts()), + itemsStack: ItemsStackState(root: .loaded(currentItems), + itemStates: currentItemStates)) } } @MainActor func reload() async { - allItems.removeAll() - currentPage = Constants.initialPage - mightHaveMorePages = true - itemListStateSubject.send(.loading(allItems)) - try? await load(pageNumber: currentPage) + do { + try await paginationTracker.resync { [weak self] pageNumber in + guard let self else { return true } + return try await fetchItems(pageNumber: pageNumber, appendToExistingItems: false) + } + } catch { + // TODO: 14694 - Handle error from pull-to-refresh, like showing an error UI at the beginning or as an overlay. + itemsViewState = .init(containerState: .error(PointOfSaleErrorState.errorOnLoadingProducts()), + itemsStack: ItemsStackState(root: .loaded([]), + itemStates: [:])) + } } @MainActor - private func load(pageNumber: Int) async throws { - do { - try await fetchItems(pageNumber: pageNumber) - mightHaveMorePages = true - updateItemListStateAfterLoadAttempt() - } catch PointOfSaleProductServiceError.pageOutOfRange { - mightHaveMorePages = false - updateItemListStateAfterLoadAttempt() - throw PointOfSaleProductServiceError.pageOutOfRange - } catch { - itemListStateSubject.send(.error(PointOfSaleErrorState.errorOnLoadingProducts())) - throw error + func loadInitialChildItems(for parent: POSItem) async { + switch parent { + case let .variableParentProduct(parentProduct): + updateState(for: parent, to: .loading([])) + do { + // TODO-14696: pagination support for variations lists + try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: Store.Default.firstPageNumber) + } catch { + // TODO: 14694 - Handle error from loading initial variations. + } + default: + assertionFailure("Unsupported parent type for loading child items: \(parent)") + return } } +} +private extension PointOfSaleItemsController { + /// Fetches items given a page number and appends new unique items to the `allItems` array. + /// - Parameter pageNumber: Page number to fetch items from. + /// - Parameter appendToExistingItems: Default true – set this to false when refreshing to make the new page the only page. + /// - Returns: A boolean that indicates whether there is next page for the paginated items. @MainActor - private func fetchItems(pageNumber: Int) async throws { - let newItems = try await itemProvider.providePointOfSaleItems(pageNumber: pageNumber) + func fetchItems(pageNumber: Int, appendToExistingItems: Bool = true) async throws -> Bool { + let pagedItems = try await itemProvider.providePointOfSaleItems(pageNumber: pageNumber) + let newItems = pagedItems.items + var allItems = appendToExistingItems ? itemsViewState.itemsStack.root.items : [] let uniqueNewItems = newItems.filter { newItem in - !allItems.contains(where: { $0.isEqual(to: newItem) }) + // Note that this uniquing won't currently work, as POSItem has a UUID. + !allItems.contains(newItem) } allItems.append(contentsOf: uniqueNewItems) - } - - private func updateItemListStateAfterLoadAttempt() { if allItems.isEmpty { - itemListStateSubject.send(.empty) + itemsViewState = .init(containerState: .empty, + itemsStack: ItemsStackState(root: .loaded([]), + itemStates: [:])) } else { - itemListStateSubject.send(.loaded(allItems)) + let itemStates = itemsViewState.itemsStack.itemStates + .filter { allItems.contains($0.key) } + itemsViewState = .init(containerState: .content, + itemsStack: ItemsStackState(root: .loaded(allItems), + itemStates: itemStates)) } + return pagedItems.hasMorePages } - private enum Constants { - static let initialPage: Int = 1 + /// Fetches variation items given a page number and appends new unique items to the existing items array. + /// - Parameter pageNumber: Page number to fetch items from. + /// - Parameter appendToExistingItems: Default true – set this to false when refreshing to make the new page the only page. + @MainActor + private func fetchVariationItems(parentProduct: POSVariableParentProduct, + parentItem: POSItem, + pageNumber: Int, + appendToExistingItems: Bool = true) async throws { + let pagedItems = try await itemProvider.providePointOfSaleVariationItems( + for: parentProduct, + pageNumber: pageNumber + ) + let newItems = pagedItems.items + var allItems: [POSItem] = appendToExistingItems ? (itemsViewState.itemsStack.itemStates[parentItem]?.items ?? []) : [] + let uniqueNewItems = newItems.filter { newItem in + // Note that this uniquing won't currently work, as POSItem has a UUID. + !allItems.contains(newItem) + } + allItems.append(contentsOf: uniqueNewItems) + + updateState(for: parentItem, to: .loaded(allItems)) + } +} + +// MARK: - ItemsViewState Updates + +private extension PointOfSaleItemsController { + func updateState(for parent: POSItem, to state: ItemListState) { + let viewState = itemsViewState + let itemStates: [POSItem: ItemListState] = { + var states = viewState.itemsStack.itemStates + states[parent] = state + return states + }() + itemsViewState = viewState.copy(itemsStack: viewState.itemsStack.copy(itemStates: itemStates)) } } diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index 69b793ab43a..8cbe2d8478f 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -1,8 +1,13 @@ import Foundation import Combine +import protocol Yosemite.StoresManager import protocol Yosemite.POSOrderServiceProtocol +import protocol Yosemite.POSReceiptServiceProtocol import struct Yosemite.Order +import struct Yosemite.PaymentGateway import struct Yosemite.POSCartItem +import enum Yosemite.OrderAction +import enum Yosemite.OrderUpdateField import class WooFoundation.CurrencyFormatter protocol PointOfSaleOrderControllerProtocol { @@ -12,14 +17,19 @@ protocol PointOfSaleOrderControllerProtocol { var order: Order? { get } func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async - func sendReceipt(recipientEmail: String) async + func sendReceipt(recipientEmail: String) async throws func clearOrder() + func collectCashPayment() async throws } final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { init(orderService: POSOrderServiceProtocol, + receiptService: POSReceiptServiceProtocol, + stores: StoresManager = ServiceLocator.stores, currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)) { self.orderService = orderService + self.receiptService = receiptService + self.stores = stores self.currencyFormatter = currencyFormatter } @@ -28,8 +38,10 @@ final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { } private let orderService: POSOrderServiceProtocol + private let receiptService: POSReceiptServiceProtocol private let currencyFormatter: CurrencyFormatter + private let stores: StoresManager @Published private var orderState: PointOfSaleInternalOrderState = .idle private(set) var order: Order? = nil @@ -70,22 +82,47 @@ final class PointOfSaleOrderController: PointOfSaleOrderControllerProtocol { })) } - func sendReceipt(recipientEmail: String) async { + func sendReceipt(recipientEmail: String) async throws { guard let order = order else { return } - do { - try await orderService.sendReceipt(order: order, recipientEmail: recipientEmail) - } catch { - // TODO: - // https://github.com/woocommerce/woocommerce-ios/issues/14464 - } + try await orderService.updatePOSOrder(order: order, recipientEmail: recipientEmail) + try await receiptService.sendReceipt(order: order, recipientEmail: recipientEmail) } func clearOrder() { order = nil orderState = .idle } + + @MainActor + func collectCashPayment() async throws { + guard let siteID = stores.sessionManager.defaultStoreID else { + throw PointOfSaleOrderControllerError.noSiteID + } + guard let order = order else { + throw PointOfSaleOrderControllerError.noOrder + } + + let fieldsToUpdate: [OrderUpdateField] = [ + .status, + .paymentMethodID, + .paymentMethodTitle] + let updatedOrder = order.copy(status: .completed, + paymentMethodID: PaymentGateway.Constants.cashOnDeliveryGatewayID, + paymentMethodTitle: Localization.cashPaymentMethodTitle) + + let _ = try await withCheckedThrowingContinuation { continuation in + let action = OrderAction.updateOrder(siteID: siteID, + order: updatedOrder, + giftCard: nil, + fields: fieldsToUpdate, + onCompletion: { result in + continuation.resume(with: result) + }) + stores.dispatch(action) + } + } } @@ -157,3 +194,16 @@ extension PointOfSaleInternalOrderState: Equatable { } } } + +extension PointOfSaleOrderController { + enum Localization { + static let cashPaymentMethodTitle = NSLocalizedString( + "pointOfSaleOrderController.collectCashPayment.paymentMethodTitle", + value: "Pay in Person", + comment: "Title for the payment method used when collecting cash payment in Point of Sale.") + } + enum PointOfSaleOrderControllerError: Error { + case noSiteID + case noOrder + } +} diff --git a/WooCommerce/Classes/POS/Models/ItemListState.swift b/WooCommerce/Classes/POS/Models/ItemListState.swift index 339274d6c7e..d3c58605cc5 100644 --- a/WooCommerce/Classes/POS/Models/ItemListState.swift +++ b/WooCommerce/Classes/POS/Models/ItemListState.swift @@ -1,14 +1,12 @@ -import protocol Yosemite.POSDisplayableItem -import protocol Yosemite.POSOrderableItem +import enum Yosemite.POSItem +import Codegen -enum ItemListState: Equatable { - case empty - case initialLoading - case loading(_ currentItems: [POSDisplayableItem]) - case loaded(_ items: [POSDisplayableItem]) +enum ItemListState { + case loading(_ currentItems: [POSItem]) + case loaded(_ items: [POSItem]) case error(PointOfSaleErrorState) - var isLoadingAfterInitialLoad: Bool { + var isLoading: Bool { switch self { case .loading: return true @@ -16,19 +14,18 @@ enum ItemListState: Equatable { return false } } +} - static func == (lhs: ItemListState, rhs: ItemListState) -> Bool { - switch (lhs, rhs) { - case (.empty, .empty), - (.initialLoading, .initialLoading): - return true - case (.loading(let lhsItems), .loading(let rhsItems)), - (.loaded(let lhsItems), .loaded(let rhsItems)): - return lhsItems.isEqual(to: rhsItems) - case (.error(let lhsError), .error(let rhsError)): - return lhsError == rhsError - default: - return false +extension ItemListState { + var items: [POSItem] { + switch self { + case .loading(let items), + .loaded(let items): + return items + case .error: + return [] } } } + +extension ItemListState: Equatable, GeneratedCopiable {} diff --git a/WooCommerce/Classes/POS/Models/ItemsContainerState.swift b/WooCommerce/Classes/POS/Models/ItemsContainerState.swift new file mode 100644 index 00000000000..4901bfa6ad3 --- /dev/null +++ b/WooCommerce/Classes/POS/Models/ItemsContainerState.swift @@ -0,0 +1,10 @@ +import Foundation + +enum ItemsContainerState { + case loading + case empty + case error(PointOfSaleErrorState) + case content +} + +extension ItemsContainerState: Equatable {} diff --git a/WooCommerce/Classes/POS/Models/ItemsStackState.swift b/WooCommerce/Classes/POS/Models/ItemsStackState.swift new file mode 100644 index 00000000000..ab914b33d26 --- /dev/null +++ b/WooCommerce/Classes/POS/Models/ItemsStackState.swift @@ -0,0 +1,10 @@ +import Foundation +import Codegen +import enum Yosemite.POSItem + +struct ItemsStackState { + let root: ItemListState + let itemStates: [POSItem: ItemListState] +} + +extension ItemsStackState: Equatable, GeneratedCopiable {} diff --git a/WooCommerce/Classes/POS/Models/ItemsViewState.swift b/WooCommerce/Classes/POS/Models/ItemsViewState.swift new file mode 100644 index 00000000000..cb706488c0f --- /dev/null +++ b/WooCommerce/Classes/POS/Models/ItemsViewState.swift @@ -0,0 +1,9 @@ +import Foundation +import Codegen + +struct ItemsViewState { + let containerState: ItemsContainerState + let itemsStack: ItemsStackState +} + +extension ItemsViewState: GeneratedCopiable, Equatable {} diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index f4608177c9d..2c09f3d933d 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -6,6 +6,8 @@ import protocol WooFoundation.Analytics import struct Yosemite.Order import struct Yosemite.OrderItem import struct Yosemite.POSCartItem +import enum Yosemite.POSItem +import enum Yosemite.SystemStatusAction protocol PointOfSaleAggregateModelProtocol { var orderStage: PointOfSaleOrderStage { get } @@ -20,10 +22,11 @@ protocol PointOfSaleAggregateModelProtocol { func cancelCardPaymentsOnboarding() func trackCardPaymentsOnboardingShown() - var itemListState: ItemListState { get } + var itemsViewState: ItemsViewState { get } func loadInitialItems() async func loadNextItems() async func reload() async + func loadInitialChildItems(for parent: POSItem) async var cart: [CartItem] { get } func addToCart(_ item: POSOrderableItem) @@ -46,7 +49,9 @@ class PointOfSaleAggregateModel: ObservableObject, PointOfSaleAggregateModelProt @Published var cardPresentPaymentOnboardingViewModel: CardPresentPaymentsOnboardingViewModel? private var onOnboardingCancellation: (() -> Void)? - @Published private(set) var itemListState: ItemListState = .initialLoading + @Published private(set) var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, + itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])) @Published private(set) var cart: [CartItem] = [] @@ -73,7 +78,7 @@ class PointOfSaleAggregateModel: ObservableObject, PointOfSaleAggregateModelProt self.orderController = orderController self.analytics = analytics self.paymentState = paymentState - publishItemListState() + publishItemsViewState() publishCardReaderConnectionStatus() publishPaymentMessages() publishOrderState() @@ -83,8 +88,8 @@ class PointOfSaleAggregateModel: ObservableObject, PointOfSaleAggregateModelProt // MARK: - ItemList extension PointOfSaleAggregateModel { - private func publishItemListState() { - itemsController.itemListStatePublisher.assign(to: &$itemListState) + private func publishItemsViewState() { + itemsController.itemsViewStatePublisher.assign(to: &$itemsViewState) } @MainActor @@ -101,6 +106,11 @@ extension PointOfSaleAggregateModel { func reload() async { await itemsController.reload() } + + @MainActor + func loadInitialChildItems(for parent: POSItem) async { + await itemsController.loadInitialChildItems(for: parent) + } } // MARK: - Cart @@ -197,8 +207,17 @@ extension PointOfSaleAggregateModel { } @MainActor - func sendReceipt(to emailAddress: String) async { - await orderController.sendReceipt(recipientEmail: emailAddress) + func collectCashPayment() async throws { + do { + try await orderController.collectCashPayment() + } catch { + debugPrint(error) + } + } + + @MainActor + func sendReceipt(to emailAddress: String) async throws { + try await orderController.sendReceipt(recipientEmail: emailAddress) } @MainActor diff --git a/WooCommerce/Classes/POS/Presentation/Card Present Payments/PointOfSaleCardPresentPaymentEventPresentationStyle.swift b/WooCommerce/Classes/POS/Presentation/Card Present Payments/PointOfSaleCardPresentPaymentEventPresentationStyle.swift index fccaf825948..0e503d46ba2 100644 --- a/WooCommerce/Classes/POS/Presentation/Card Present Payments/PointOfSaleCardPresentPaymentEventPresentationStyle.swift +++ b/WooCommerce/Classes/POS/Presentation/Card Present Payments/PointOfSaleCardPresentPaymentEventPresentationStyle.swift @@ -244,6 +244,14 @@ enum PointOfSaleCardPresentPaymentEventPresentationStyle { /// Not-yet supported types case .selectSearchType: return nil + /// Immediately request location permission until POS view is created + case .locationRequestPreAlert(let requestPermission): + requestPermission() + return nil + /// Skip location required step and rely on error during the payment process until POS view is created + case .locationRequired(_, let skip): + skip() + return nil } } } diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleCardPresentPaymentInLineMessage.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleCardPresentPaymentInLineMessage.swift index efa1630d5b1..51e9fc346f9 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleCardPresentPaymentInLineMessage.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleCardPresentPaymentInLineMessage.swift @@ -55,4 +55,5 @@ struct POSCardPresentPaymentInLineMessageAnimation { let iconTransitionId: String = "pos_card_present_payment_in_line_message_icon_matched_geometry_id" let titleTransitionId: String = "pos_card_present_payment_in_line_message_title_matched_geometry_id" let messageTransitionId: String = "pos_card_present_payment_in_line_message_message_matched_geometry_id" + let actionButtonsTransitionId = "pos_card_present_payment_in_line_message_action_buttons_matched_geometry_id" } diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenView.swift index bf0d1d3ae69..21ce6d37a64 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenView.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/PointOfSaleItemListFullscreenView.swift @@ -6,7 +6,10 @@ struct PointOfSaleItemListFullscreenView: View { var body: some View { ZStack { VStack(alignment: .center, spacing: PointOfSaleItemListErrorLayout.headerSpacing) { - POSHeaderTitleView(foregroundColor: .posSecondaryText) + POSHeaderTitleView( + title: Localization.title, + foregroundColor: .posSecondaryText + ) Spacer() } @@ -16,6 +19,14 @@ struct PointOfSaleItemListFullscreenView: View { } } +private enum Localization { + static let title = NSLocalizedString( + "pos.itemListFullscreen.title", + value: "Products", + comment: "Title at the top of the Point of Sale item list full screen." + ) +} + #Preview { PointOfSaleItemListFullscreenView( content: { diff --git a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentSuccessMessageView.swift b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentSuccessMessageView.swift index 0af318967f4..a4ceb3cd62d 100644 --- a/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentSuccessMessageView.swift +++ b/WooCommerce/Classes/POS/Presentation/CardReaderConnection/UI States/Reader Messages/PointOfSaleCardPresentPaymentSuccessMessageView.swift @@ -1,30 +1,55 @@ import SwiftUI struct PointOfSaleCardPresentPaymentSuccessMessageView: View { + let viewModel: PointOfSaleCardPresentPaymentSuccessMessageViewModel let animation: POSCardPresentPaymentInLineMessageAnimation @Environment(\.colorScheme) var colorScheme + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + @State private var isShowingSendReceiptView: Bool = false + @State private var isShowingReceiptNotEligibleBanner: Bool = false var body: some View { - VStack(alignment: .center, spacing: Constants.headerSpacing) { - successIcon - .matchedGeometryEffect(id: animation.iconTransitionId, in: animation.namespace, properties: .position) - VStack(alignment: .center, spacing: Constants.textSpacing) { - Text(viewModel.title) - .font(.posTitleEmphasized) - .foregroundStyle(Color.posPrimaryText) - .accessibilityAddTraits(.isHeader) - .matchedGeometryEffect(id: animation.titleTransitionId, in: animation.namespace, properties: .position) + if isShowingSendReceiptView { + POSSendReceiptView(isShowingSendReceiptView: $isShowingSendReceiptView) + } else { + ZStack { + VStack(alignment: .center, spacing: Constants.headerSpacing) { + successIcon + .renderedIf(!dynamicTypeSize.isAccessibilitySize) + .matchedGeometryEffect(id: animation.iconTransitionId, in: animation.namespace, properties: .position) + VStack(alignment: .center, spacing: Constants.textSpacing) { + Text(viewModel.title) + .font(.posTitleEmphasized) + .foregroundStyle(Color.posPrimaryText) + .accessibilityAddTraits(.isHeader) + .matchedGeometryEffect(id: animation.titleTransitionId, in: animation.namespace, properties: .position) + + if let message = viewModel.message { + Text(message) + .font(.posBodyRegular) + .foregroundStyle(Color.posPrimaryText) + .matchedGeometryEffect(id: animation.messageTransitionId, in: animation.namespace, properties: .position) + } + } + PaymentsActionButtons(isShowingSendReceiptView: $isShowingSendReceiptView, + isShowingReceiptNotEligibleBanner: $isShowingReceiptNotEligibleBanner) + .matchedGeometryEffect(id: animation.actionButtonsTransitionId, in: animation.namespace, properties: .position) + } + .multilineTextAlignment(.center) - if let message = viewModel.message { - Text(message) - .font(.posBodyRegular) - .foregroundStyle(Color.posPrimaryText) - .matchedGeometryEffect(id: animation.messageTransitionId, in: animation.namespace, properties: .position) + if isShowingReceiptNotEligibleBanner { + VStack { + Spacer() + POSReceiptEligibilityBanner(isVisible: $isShowingReceiptNotEligibleBanner) + .transition(.move(edge: .bottom)) + .padding(.bottom) + } + .edgesIgnoringSafeArea(.bottom) } } } - .multilineTextAlignment(.center) } private var successIcon: some View { @@ -47,7 +72,7 @@ struct PointOfSaleCardPresentPaymentSuccessMessageView: View { } private var checkmarkColor: Color { - Color.posSecondaryBackground + Color.primary } } diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index 383e24b8e84..e4dbb22fce6 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -62,7 +62,7 @@ struct CartView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, POSHeaderLayoutConstants.sectionHorizontalPadding) .padding(.vertical, POSHeaderLayoutConstants.sectionVerticalPadding) - .if(shouldApplyHeaderBottomShadow, transform: { $0.applyBottomShadow() }) + .if(shouldApplyHeaderBottomShadow, transform: { $0.applyBottomShadow(backgroundColor: backgroundColor) }) if posModel.cart.isNotEmpty { ScrollViewReader { proxy in @@ -78,7 +78,6 @@ struct CartView: View { } } .animation(Constants.cartAnimation, value: posModel.cart.map(\.id)) - .padding(.bottom, floatingControlAreaSize.height) .background(GeometryReader { geometry in Color.clear.preference(key: ScrollOffSetPreferenceKey.self, value: geometry.frame(in: coordinateSpace).origin.y) @@ -86,6 +85,10 @@ struct CartView: View { .onPreferenceChange(ScrollOffSetPreferenceKey.self) { position in self.offSetPosition = position } + + Spacer() + .frame(height: floatingControlAreaSize.height) + .renderedIf(posModel.orderStage == .finalizing) } .coordinateSpace(name: Constants.scrollViewCoordinateSpaceIdentifier) .onChange(of: posModel.cart.first?.id) { itemToScrollTo in @@ -251,6 +254,12 @@ private extension CartView { #if DEBUG #Preview { - CartView() + let itemsController = PointOfSalePreviewItemsController() + let posModel = PointOfSaleAggregateModel( + itemsController: PointOfSalePreviewItemsController(), + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + return CartView() + .environmentObject(posModel) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift new file mode 100644 index 00000000000..0a674606cc6 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ChildItemList.swift @@ -0,0 +1,118 @@ +import SwiftUI +import Yosemite + +/// Displays a scrollable list of child items in POS. +struct ChildItemList: View { + private let parentItem: POSItem + private let title: String + @EnvironmentObject private var posModel: PointOfSaleAggregateModel + @Environment(\.dismiss) private var dismiss + @Environment(\.floatingControlAreaSize) private var floatingControlAreaSize: CGSize + + private var state: ItemListState { + posModel.itemsViewState.itemsStack + .itemStates[parentItem] ?? + .loading([]) + } + + init(parentItem: POSItem, title: String) { + self.parentItem = parentItem + self.title = title + } + + var body: some View { + VStack { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.backward") + .font(.posBodyEmphasized, maximumContentSizeCategory: .accessibilityLarge) + .foregroundColor(.primary) + } + POSHeaderTitleView(title: title) + Spacer() + } + .padding(.horizontal, Constants.itemListPadding) + ScrollView { + VStack { + ItemList(state: state) + .background(Color.posPrimaryBackground) + .toolbar(.hidden, for: .navigationBar) + .transition(.opacity) + } + .frame(maxWidth: .infinity) + .padding(.bottom, floatingControlAreaSize.height) + .padding(.horizontal, Constants.itemListPadding) + } + } + .task { + guard state.items.isEmpty else { + return + } + await posModel.loadInitialChildItems(for: parentItem) + } + } +} + +private extension ChildItemList { + enum Localization { + static let back = NSLocalizedString( + "pos.childItemList.back", + value: "Back", + comment: "Back button title in the child item list screen." + ) + } + + enum Constants { + static let itemListPadding: CGFloat = 16 + } +} + +#if DEBUG + +#Preview("Variable product child items") { + let parentProduct = POSVariableParentProduct( + id: .init(), + name: "Variable latte", + productImageSource: nil, + productID: 1 + ) + let parentItem = POSItem.variableParentProduct(parentProduct) + let itemsController = PointOfSalePreviewItemsController() + itemsController.itemsViewState = .init(containerState: .content, + itemsStack: ItemsStackState( + root: .loading([]), + itemStates: [ + parentItem: .loaded( + [ + .variation( + POSVariation( + id: .init(), + name: "Cinamon chestnut latte", + formattedPrice: "$5.75", + price: "5.75", + productID: 134, + variationID: 256 + ) + ), + .variation( + POSVariation( + id: .init(), + name: "Choco latte", + formattedPrice: "$6.5", + price: "6.5", + productID: 134, + variationID: 256 + ) + ) + ])])) + let posModel = PointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + return ChildItemList(parentItem: parentItem, title: parentProduct.name) + .environmentObject(posModel) +} + +#endif diff --git a/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift new file mode 100644 index 00000000000..129c6cc9ee2 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift @@ -0,0 +1,93 @@ +import SwiftUI +import enum Yosemite.POSItem +import struct Yosemite.POSVariableParentProduct + +/// Displays a list of POS items or placeholder card based on the given state. +struct ItemList: View { + let state: ItemListState + + var body: some View { + ForEach(state.items) { item in + ItemListRow(item: item) + } + GhostItemCardView() + .renderedIf(state.isLoading) + } +} + +private struct ItemListRow: View { + let item: POSItem + @EnvironmentObject var posModel: PointOfSaleAggregateModel + + var body: some View { + switch item { + case let .simpleProduct(product): + Button(action: { + posModel.addToCart(product) + }, label: { + SimpleProductCardView(product: product) + }) + case let .variableParentProduct(parentProduct): + NavigationLink(value: item) { + ParentProductCardView(name: parentProduct.name, + imageSource: parentProduct.productImageSource, + detailView: { + Text(Localization.variationsAvailable) + .foregroundStyle(Color.posSecondaryText) + .font(.posBodyRegular) + }) + } + case let .variation(variation): + Button(action: { + posModel.addToCart(variation) + }, label: { + VariationCardView(variation: variation) + }) + } + } +} + +private extension ItemListRow { + enum Localization { + static let variationsAvailable = NSLocalizedString( + "pos.parentProductCard.optionsAvailable", + value: "Options available", + comment: "Text indicating that there are options available for a parent product" + ) + } +} + +#if DEBUG + +#Preview("Loaded with items") { + ItemList( + state: + .loaded( + [ + .simpleProduct( + .init( + id: .init(), + name: "Strong latte 16oz", + formattedPrice: "$4.00", + productID: 12, + price: "4.00" + ) + ), + .variableParentProduct( + .init( + id: .init(), + name: "Variable mocha", + productImageSource: "https://pd.w.org/2024/12/986762d0d4d4cf17.82435881-scaled.jpeg", + productID: 16 + ) + ) + ] + ) + ) +} + +#Preview("Loading") { + ItemList(state: .loading([])) +} + +#endif diff --git a/WooCommerce/Classes/POS/Presentation/ItemCardView.swift b/WooCommerce/Classes/POS/Presentation/ItemCardView.swift deleted file mode 100644 index 4d9b0c4d19e..00000000000 --- a/WooCommerce/Classes/POS/Presentation/ItemCardView.swift +++ /dev/null @@ -1,81 +0,0 @@ -import protocol Yosemite.POSDisplayableItem -import SwiftUI - -struct ItemCardView: View { - private let item: POSDisplayableItem - - @ScaledMetric private var scale: CGFloat = 1.0 - @Environment(\.dynamicTypeSize) var dynamicTypeSize - - init(item: POSDisplayableItem) { - self.item = item - } - - var body: some View { - HStack(spacing: Constants.cardSpacing) { - if let imageSource = item.productImageSource { - ProductImageThumbnail(productImageURL: URL(string: imageSource), - productImageSize: Constants.productCardSize * scale, - scale: scale, - foregroundColor: .clear, - cachesOriginalImage: true) - .frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize), - height: Constants.productCardSize * scale) - .clipped() - } else { - Rectangle() - .frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize), - height: Constants.productCardSize * scale) - .foregroundColor(Color(.secondarySystemFill)) - } - - DynamicHStack(spacing: Constants.textSpacing) { - Spacer().renderedIf(dynamicTypeSize.isAccessibilitySize) - Text(item.name) - .lineLimit(2) - .foregroundStyle(Color.posPrimaryText) - .multilineTextAlignment(.leading) - .font(Constants.itemNameFont) - Spacer() - Text(item.formattedPrice) - .foregroundStyle(Color.posPrimaryText) - .font(Constants.itemPriceFont) - Spacer().renderedIf(dynamicTypeSize.isAccessibilitySize) - } - .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) - .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) - Spacer() - } - .frame(maxWidth: .infinity, idealHeight: Constants.productCardSize * scale) - .background(Color.posSecondaryBackground) - .overlay { - RoundedRectangle(cornerRadius: Constants.productCardCornerRadius) - .stroke(Color.black, lineWidth: Constants.nilOutline) - } - .clipShape(RoundedRectangle(cornerRadius: Constants.productCardCornerRadius)) - .shadow(color: Color.black.opacity(0.08), radius: 4, y: 2) - } -} - -private extension ItemCardView { - enum Constants { - static let productCardSize: CGFloat = 112 - static let maximumProductCardSize: CGFloat = Constants.productCardSize * 2 - static let productCardCornerRadius: CGFloat = 8 - // The use of stroke means the shape is rendered as an outline (border) rather than a filled shape, - // since we still have to give it a value, we use 0 so it renders no border but it's shaped as one. - static let nilOutline: CGFloat = 0 - static let cardSpacing: CGFloat = 0 - static let textSpacing: CGFloat = 8 - static let horizontalTextPadding: CGFloat = 32 - static let verticalTextPadding: CGFloat = 8 - static let itemNameFont: POSFontStyle = .posBodyEmphasized - static let itemPriceFont: POSFontStyle = .posBodyRegular - } -} - -#if DEBUG -#Preview { - ItemCardView(item: PointOfSalePreviewItemService().providePointOfSaleItem()) -} -#endif diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index 7a410e81a80..01efa335532 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -1,5 +1,5 @@ import SwiftUI -import protocol Yosemite.POSDisplayableItem +import enum Yosemite.POSItem import protocol Yosemite.POSOrderableItem struct ItemListView: View { @@ -10,21 +10,30 @@ struct ItemListView: View { @State private var lastScrollPosition: CGFloat = 0 @State private var showSimpleProductsModal: Bool = false + private var itemListState: ItemListState { + posModel.itemsViewState.itemsStack.root + } @AppStorage(BannerState.isSimpleProductsOnlyBannerDismissedKey) private var isHeaderBannerDismissed: Bool = false var body: some View { - VStack { - headerView - switch posModel.itemListState { - case .initialLoading, .empty, .error: - // These cases are handled directly in the dashboard, we do not render - // a specific view within the ItemListView to handle them - EmptyView() - case .loading(let items), .loaded(let items): - listView(items) + NavigationStack { + VStack { + headerView + switch itemListState { + case .loading(let items), + .loaded(let items): + listView(items) + case .error: + // Currently unused, but this will show errors that are displayed inline with previously + // loaded items, e.g. when loading a new page or refreshing. + EmptyView() + } } + .navigationDestination(for: POSItem.self, destination: { item in + childListView(parentItem: item) + }) } .refreshable { await posModel.reload() @@ -44,7 +53,7 @@ private extension ItemListView { var headerView: some View { VStack { HStack { - POSHeaderTitleView() + POSHeaderTitleView(title: Localization.title) if !shouldShowHeaderBanner { Spacer() Button(action: { @@ -124,17 +133,13 @@ private extension ItemListView { } @ViewBuilder - func listView(_ items: [POSDisplayableItem]) -> some View { + func listView(_ items: [POSItem]) -> some View { ScrollView { VStack { if dynamicTypeSize.isAccessibilitySize, shouldShowHeaderBanner { bannerCardView } - ForEach(items, id: \.id) { item in - listRow(item: item) - } - GhostItemCardView() - .renderedIf(posModel.itemListState.isLoadingAfterInitialLoad) + ItemList(state: itemListState) } .frame(maxWidth: .infinity) .padding(.bottom, floatingControlAreaSize.height) @@ -142,7 +147,7 @@ private extension ItemListView { .background(GeometryReader { proxy in Color.clear .onChange(of: proxy.frame(in: .global).maxY) { maxY in - if posModel.itemListState.isLoadingAfterInitialLoad { + if itemListState.isLoading { return } let threshold = Constants.viewHeight * Constants.scrollThresholdMultiplier @@ -158,22 +163,19 @@ private extension ItemListView { } @ViewBuilder - func listRow(item: POSDisplayableItem) -> some View { - if let item = item as? POSOrderableItem { - Button(action: { - posModel.addToCart(item) - }, label: { - ItemCardView(item: item) - }) - } else { - ItemCardView(item: item) + func childListView(parentItem: POSItem) -> some View { + switch parentItem { + case let .variableParentProduct(parentProduct): + ChildItemList(parentItem: parentItem, title: parentProduct.name) + default: + EmptyView() } } } private extension ItemListView { var shouldShowHeaderBanner: Bool { - posModel.itemListState.eligibleToShowSimpleProductsBanner && !isHeaderBannerDismissed + itemListState.eligibleToShowSimpleProductsBanner && !isHeaderBannerDismissed } } @@ -183,9 +185,7 @@ private extension ItemListState { case .loading, .loaded: return true - case .empty, - .initialLoading, - .error: + case .error: return false } } @@ -254,6 +254,12 @@ private extension ItemListView { } enum Localization { + static let title = NSLocalizedString( + "pos.itemlistview.title", + value: "Products", + comment: "Title at the top of the Point of Sale product selector screen." + ) + static let headerBannerTitle = NSLocalizedString( "pos.itemlistview.headerBanner.title", value: "Showing simple products only", @@ -288,7 +294,27 @@ private extension ItemListView { } #if DEBUG -#Preview { - ItemListView() + +#Preview("Loaded with all product types") { + let itemsController = PointOfSalePreviewItemsController() + Task { @MainActor in + await itemsController.loadInitialItems() + } + let posModel = PointOfSaleAggregateModel( + itemsController: itemsController, + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + return ItemListView() + .environmentObject(posModel) } + +#Preview("Loading") { + let posModel = PointOfSaleAggregateModel( + itemsController: PointOfSalePreviewItemsController(), + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + return ItemListView() + .environmentObject(posModel) +} + #endif diff --git a/WooCommerce/Classes/POS/Presentation/POSReceiptEligibilityBanner.swift b/WooCommerce/Classes/POS/Presentation/POSReceiptEligibilityBanner.swift new file mode 100644 index 00000000000..f29131e8356 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/POSReceiptEligibilityBanner.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct POSReceiptEligibilityBanner: View { + @Binding var isVisible: Bool + + var body: some View { + HStack(spacing: Constants.elementSpacing) { + Image(uiImage: .appIconDefault) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Constants.imagesize, height: Constants.imagesize) + .clipShape(Circle()) + .padding(Constants.imagePadding) + Text(Localization.updateWooCommerceVersionText) + .foregroundColor(Color.posPrimaryText) + } + .padding() + .background(Color.posPrimaryBackground) + .cornerRadius(Constants.cornerRadius) + .padding(.horizontal, Constants.bannerPadding) + .onTapGesture { + withAnimation { + isVisible = false + } + } + } +} + +private extension POSReceiptEligibilityBanner { + enum Constants { + static let elementSpacing: CGFloat = 8 + static let cornerRadius: CGFloat = 20 + static let imagesize: CGFloat = 40 + static let imagePadding: CGFloat = 4 + static let bannerPadding: CGFloat = 16 + } + + enum Localization { + static let updateWooCommerceVersionText = NSLocalizedString( + "pos.totalsView.receipts.banner.updateWooCommerceVersionText", + value: "Please update WooCommerce to version 9.5.0", + comment: "Text for the banner requiring specific WooCommerce version.") + } +} diff --git a/WooCommerce/Classes/POS/Presentation/ParentProductCardView.swift b/WooCommerce/Classes/POS/Presentation/ParentProductCardView.swift new file mode 100644 index 00000000000..b3d0af025e3 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/ParentProductCardView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +/// Displays a card for a parent product in POS. +struct ParentProductCardView: View { + private let name: String + private let imageSource: String? + private let detailView: DetailView + + @ScaledMetric private var scale: CGFloat = 1.0 + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + init(name: String, imageSource: String?, @ViewBuilder detailView: () -> DetailView) { + self.name = name + self.imageSource = imageSource + self.detailView = detailView() + } + + var body: some View { + HStack(spacing: Constants.cardSpacing) { + POSItemImageView(imageSource: imageSource, + imageSize: Constants.productCardSize * scale, + scale: scale) + .frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize), + height: Constants.productCardSize * scale) + .clipped() + + VStack(alignment: .leading, spacing: Constants.textSpacing) { + Text(name) + .lineLimit(2) + .foregroundStyle(Color.posPrimaryText) + .multilineTextAlignment(.leading) + .font(Constants.itemNameFont) + + detailView + } + .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) + .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) + Spacer() + } + .frame(maxWidth: .infinity, idealHeight: Constants.productCardSize * scale) + .background(Color.posSecondaryBackground) + .posItemCardBorderStyles() + } +} + +private enum Constants { + static let productCardSize: CGFloat = 112 + static let maximumProductCardSize: CGFloat = Constants.productCardSize * 2 + static let cardSpacing: CGFloat = 0 + static let textSpacing: CGFloat = 8 + static let horizontalTextPadding: CGFloat = 32 + static let verticalTextPadding: CGFloat = 8 + static let itemNameFont: POSFontStyle = .posBodyEmphasized +} + +#if DEBUG +#Preview { + ParentProductCardView(name: "Parent product", + imageSource: nil) { + Text("Detail view") + } +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/PaymentButtons.swift b/WooCommerce/Classes/POS/Presentation/PaymentButtons.swift new file mode 100644 index 00000000000..127f5e6bb23 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/PaymentButtons.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct PaymentsActionButtons: View { + @EnvironmentObject private var posModel: PointOfSaleAggregateModel + @Binding var isShowingSendReceiptView: Bool + @Binding private(set) var isShowingReceiptNotEligibleBanner: Bool + + private let receiptEligibilityUseCase = ReceiptEligibilityUseCase() + + private var shouldShowSendReceiptButton: Bool { + ServiceLocator.featureFlagService.isFeatureFlagEnabled(.sendReceiptsForPointOfSale) + } + + var body: some View { + ZStack { + VStack { + newOrderButton + sendReceiptButton + .renderedIf(shouldShowSendReceiptButton) + } + } + } +} + +private extension PaymentsActionButtons { + var sendReceiptButton: some View { + Button(action: { + Task { @MainActor in + await handleSendReceiptAction() + } + }, label: { + HStack(spacing: Constants.buttonSpacing) { + Text(Localization.sendReceipt) + .font(Constants.buttonFont) + } + .frame(minWidth: UIScreen.main.bounds.width / 2) + }) + .padding(Constants.buttonPadding) + .foregroundColor(Color.posPrimaryText) + .background(Color.clear) + .overlay { + RoundedRectangle(cornerRadius: Constants.buttonCornerRadius) + .stroke(Color.posPrimaryText, lineWidth: 1.0) + } + } + + var newOrderButton: some View { + Button(action: { + posModel.startNewCart() + }, label: { + HStack(spacing: Constants.buttonSpacing) { + Text(Localization.newOrder) + .font(Constants.buttonFont) + } + .frame(minWidth: UIScreen.main.bounds.width / 2) + }) + .padding(Constants.buttonPadding) + .foregroundColor(Color.posPrimaryTextInverted) + .background(Color.posPrimaryButtonBackground) + .cornerRadius(Constants.buttonCornerRadius) + } +} + +private extension PaymentsActionButtons { + func handleSendReceiptAction() async { + let isEligible = await checkReceiptEligibility() + if isEligible { + isShowingSendReceiptView = true + } else { + isShowingReceiptNotEligibleBanner = true + } + } + + func checkReceiptEligibility() async -> Bool { + await withCheckedContinuation { continuation in + receiptEligibilityUseCase.isEligibleForPointOfSaleReceipts { isEligible in + continuation.resume(returning: isEligible) + } + } + } +} + +private extension PaymentsActionButtons { + enum Constants { + static let buttonSpacing: CGFloat = 12 + static let buttonPadding: CGFloat = 32 + static let buttonFont: POSFontStyle = .posBodyEmphasized + static let buttonCornerRadius: CGFloat = 8 + } + + enum Localization { + static let newOrder = NSLocalizedString( + "pos.totalsView.button.newOrder", + value: "New order", + comment: "Button title for new order button") + static let sendReceipt = NSLocalizedString( + "pos.totalsView.button.sendReceipt", + value: "Email receipt", + comment: "Button title for the receipt button") + } +} + +#if DEBUG +#Preview { + let posModel = PointOfSaleAggregateModel( + itemsController: PointOfSalePreviewItemsController(), + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + PaymentsActionButtons(isShowingSendReceiptView: .constant(false), isShowingReceiptNotEligibleBanner: .constant(true)) + .environmentObject(posModel) +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleCollectCashView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleCollectCashView.swift new file mode 100644 index 00000000000..a4cf138c04e --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleCollectCashView.swift @@ -0,0 +1,168 @@ +import SwiftUI + +struct PointOfSaleCollectCashView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject private var posModel: PointOfSaleAggregateModel + @FocusState private var isTextFieldFocused: Bool + + @State private var textFieldAmountInput: String = "" + @State private var isLoading: Bool = false + @State private var errorMessage: String? + + let orderTotal: String + + private var formattedOrderTotal: String { + String.localizedStringWithFormat(Localization.backNavigationSubtitle, orderTotal) + } + + private func validateAmount() -> Bool { + // TODO: + // Validate amount entered vs order total + // https://github.com/woocommerce/woocommerce-ios/issues/14749 + return true + } + + @StateObject private var textFieldViewModel = FormattableAmountTextFieldViewModel(size: .extraLarge, + locale: Locale.autoupdatingCurrent, + storeCurrencySettings: ServiceLocator.currencySettings, + allowNegativeNumber: false) + + var body: some View { + VStack(alignment: .center, spacing: 20) { + HStack { + Button(action: { + dismiss() + }, label: { + VStack { + HStack { + Image(systemName: "chevron.left") + Text(Localization.backNavigationTitle) + } + .font(.posTitleRegular) + .bold() + .foregroundColor(.primary) + + Text(formattedOrderTotal) + .font(.posBodyRegular) + .foregroundColor(.primary) + } + }) + Spacer() + } + .padding() + + FormattableAmountTextField(viewModel: textFieldViewModel, style: .pos) + .onChange(of: textFieldViewModel.amount) { newValue in + textFieldAmountInput = newValue + } + + if let errorMessage = errorMessage { + Text(errorMessage) + .font(POSFontStyle.posBodyRegular) + .foregroundColor(.red) + } + + Button(action: { + Task { @MainActor in + guard validateAmount() else { + return + } + isLoading = true + do { + try await markComplete() + // TODO: + // Redirect to success view on completion + // https://github.com/woocommerce/woocommerce-ios/issues/14602 + } catch { + debugPrint(error) + } + isLoading = false + } + }, label: { + HStack(spacing: Constants.buttonSpacing) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(Color.posPrimaryTextInverted) + } else { + Text(Localization.markPaymentCompletedButtonTitle) + .font(Constants.buttonFont) + } + } + .frame(maxWidth: .infinity) + }) + .padding(Constants.buttonPadding) + .frame(maxWidth: .infinity) + .foregroundColor(Color.posPrimaryTextInverted) + .background(Color.posOverlayFillInverted) + .cornerRadius(Constants.buttonCornerRadius) + .contentShape(Rectangle()) + .disabled(isLoading) + + Spacer() + } + .background(backgroundColor) + .padding() + .animation(.easeInOut, value: errorMessage) + .onChange(of: textFieldAmountInput) { _ in + errorMessage = nil + } + } + + private func markComplete() async throws { + do { + try await posModel.collectCashPayment() + } catch { + debugPrint(error) + } + } +} + +private extension PointOfSaleCollectCashView { + enum Constants { + static let buttonSpacing: CGFloat = 12 + static let buttonPadding: CGFloat = 32 + static let buttonFont: POSFontStyle = .posBodyEmphasized + static let buttonCornerRadius: CGFloat = 8 + } + + private var backgroundColor: Color { + switch colorScheme { + case .dark: + return Color.posSecondaryBackground + default: + return .clear + } + } + + enum Localization { + static let backNavigationTitle = NSLocalizedString( + "pointOfSale.cashview.back.navigation.title", + value: "Cash payment", + comment: "Title for the cash payment view navigation back button" + ) + static let backNavigationSubtitle = NSLocalizedString( + "pointOfSale.cashview.back.navigation.subtitle", + value: "Total: %1$@", + comment: "Subtitle for the cash payment view navigation back button" + + "Reads as 'Total: $1.23'" + ) + static let markPaymentCompletedButtonTitle = NSLocalizedString( + "pointOfSale.cashview.button.markpaymentcompleted.title", + value: "Mark payment as complete", + comment: "Button to mark a cash payment as completed" + ) + } +} + +#if DEBUG +#Preview { + let posModel = PointOfSaleAggregateModel( + itemsController: PointOfSalePreviewItemsController(), + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + PointOfSaleCollectCashView(orderTotal: "$1.23") + .environmentObject(posModel) +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift index 607806f46e0..1e7678da2bf 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift @@ -10,8 +10,8 @@ struct PointOfSaleDashboardView: View { var body: some View { ZStack(alignment: .bottomLeading) { - switch posModel.itemListState { - case .initialLoading: + switch posModel.itemsViewState.containerState { + case .loading: PointOfSaleLoadingView() .transition(.opacity) .ignoresSafeArea() @@ -23,7 +23,7 @@ struct PointOfSaleDashboardView: View { await posModel.loadInitialItems() } }) - case .loading, .loaded: + case .content: contentView .accessibilitySortPriority(2) } @@ -34,7 +34,7 @@ struct PointOfSaleDashboardView: View { .offset(x: Constants.floatingControlHorizontalOffset, y: -Constants.floatingControlVerticalOffset) .trackSize(size: $floatingSize) .accessibilitySortPriority(1) - .renderedIf(posModel.itemListState != .initialLoading) + .renderedIf(posModel.itemsViewState.containerState != .loading) POSConnectivityView() } @@ -42,7 +42,7 @@ struct PointOfSaleDashboardView: View { CGSizeMake(floatingSize.width + Constants.floatingControlHorizontalOffset, floatingSize.height + Constants.floatingControlVerticalOffset)) .environment(\.posBackgroundAppearance, posModel.paymentState != .processingPayment ? .primary : .secondary) - .animation(.easeInOut, value: posModel.itemListState == .initialLoading) + .animation(.easeInOut, value: posModel.itemsViewState.containerState == .loading) .background(Color.posPrimaryBackground) .navigationBarBackButtonHidden(true) .posModal(item: $posModel.cardPresentPaymentOnboardingViewModel, onDismiss: { diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSBottomShadowViewModifier.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSBottomShadowViewModifier.swift index 327e6d96d67..21e366f1093 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSBottomShadowViewModifier.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSBottomShadowViewModifier.swift @@ -3,18 +3,20 @@ import SwiftUI /// Applies a shadow to the bottom of the View component /// struct POSBottomShadowViewModifier: ViewModifier { + let backgroundColor: Color + func body(content: Content) -> some View { content .background( - Color.white - .shadow(color: Color(.secondarySystemFill), radius: 10, x: 0, y: 0) - .mask(Rectangle().padding(.bottom, -20)) - ) + backgroundColor + .shadow(color: Color(.secondarySystemFill), radius: 10, x: 0, y: 0) + .mask(Rectangle().padding(.bottom, -20)) + ) } } extension View { - func applyBottomShadow() -> some View { - self.modifier(POSBottomShadowViewModifier()) + func applyBottomShadow(backgroundColor: Color) -> some View { + self.modifier(POSBottomShadowViewModifier(backgroundColor: backgroundColor)) } } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSHeaderTitleView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSHeaderTitleView.swift index 20b28f48318..2851fcbe044 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSHeaderTitleView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSHeaderTitleView.swift @@ -1,10 +1,16 @@ import SwiftUI struct POSHeaderTitleView: View { - var foregroundColor: Color = Color.posPrimaryText + private let title: String + private let foregroundColor: Color + + init(title: String, foregroundColor: Color = .posPrimaryText) { + self.title = title + self.foregroundColor = foregroundColor + } var body: some View { - Text(Localization.productSelectorTitle) + Text(title) .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.padding) .font(.posTitleEmphasized) @@ -14,14 +20,6 @@ struct POSHeaderTitleView: View { } private extension POSHeaderTitleView { - enum Localization { - static let productSelectorTitle = NSLocalizedString( - "pos.headerTitleView.productSelectorTitle", - value: "Products", - comment: "Title at the top of the Point of Sale product selector screen." - ) - } - enum Constants { static let padding: EdgeInsets = .init(top: POSHeaderLayoutConstants.sectionVerticalPadding, leading: POSHeaderLayoutConstants.sectionHorizontalPadding, @@ -31,5 +29,5 @@ private extension POSHeaderTitleView { } #Preview { - POSHeaderTitleView() + POSHeaderTitleView(title: "Products") } diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemCardBorderStylesModifier.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemCardBorderStylesModifier.swift new file mode 100644 index 00000000000..8f2545d08ec --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemCardBorderStylesModifier.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct POSItemCardBorderStylesModifier: ViewModifier { + func body(content: Content) -> some View { + content + .overlay { + RoundedRectangle(cornerRadius: Constants.cardCornerRadius) + .stroke(Color.black, lineWidth: Constants.nilOutline) + } + .clipShape(RoundedRectangle(cornerRadius: Constants.cardCornerRadius)) + .shadow(color: Color.black.opacity(0.08), radius: 4, y: 2) + } +} + +private extension POSItemCardBorderStylesModifier { + enum Constants { + static let cardCornerRadius: CGFloat = 8.0 + // The use of stroke means the shape is rendered as an outline (border) rather than a filled shape, + // since we still have to give it a value, we use 0 so it renders no border but it's shaped as one. + static let nilOutline: CGFloat = 0 + } +} + +extension View { + /// Applies the POS item card border styles to the view. + func posItemCardBorderStyles() -> some View { + self.modifier(POSItemCardBorderStylesModifier()) + } +} diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemImageView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemImageView.swift new file mode 100644 index 00000000000..4f28c1aca86 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSItemImageView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// A view that displays an image in a POS item view. +struct POSItemImageView: View { + let imageSource: String? + let imageSize: CGFloat + let scale: CGFloat + + var body: some View { + if let imageSource = imageSource { + ProductImageThumbnail(productImageURL: URL(string: imageSource), + productImageSize: imageSize, + scale: scale, + foregroundColor: .clear, + cachesOriginalImage: true) + } else { + Rectangle() + .foregroundColor(Color(.secondarySystemFill)) + } + } +} + +#Preview("Placeholder") { + POSItemImageView(imageSource: nil, + imageSize: 112, + scale: 1) +} + +#Preview("Image") { + POSItemImageView(imageSource: "https://pd.w.org/2024/12/986762d0d4d4cf17.82435881-scaled.jpeg", + imageSize: 112, + scale: 1) +} diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptModalView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptModalView.swift deleted file mode 100644 index 892d2bb3b0e..00000000000 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptModalView.swift +++ /dev/null @@ -1,75 +0,0 @@ -import SwiftUI - -struct POSSendReceiptModalView: View { - let sendReceipt: (String) -> () - - @State private var textFieldInput: String = "" - @Binding var isPresented: Bool - - var body: some View { - ZStack(alignment: .topTrailing) { - VStack(alignment: .leading, spacing: 20) { - Text(Localization.title) - .font(.largeTitle) - .bold() - - Text(Localization.subtitle) - .font(.headline) - - TextField(Localization.textfieldPlaceholder, text: $textFieldInput) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.none) - .autocorrectionDisabled() - .textFieldStyle(RoundedBorderTextFieldStyle(focused: true)) - .padding(.horizontal) - - Button(action: { - sendReceipt(textFieldInput) - }, label: { - HStack(spacing: Constants.buttonSpacing) { - Text(Localization.buttonTitle) - .font(Constants.buttonFont) - } - }) - .padding(Constants.buttonPadding) - .foregroundColor(Color.posPrimaryTextInverted) - .background(Color.posOverlayFillInverted) - .cornerRadius(Constants.buttonCornerRadius) - - Spacer() - } - .padding(.top) - .padding() - } - .posModalCloseButton(action: { - isPresented = false - }) - } -} - -private extension POSSendReceiptModalView { - struct Localization { - static let title = NSLocalizedString( - "pointOfSale.sendreceipt.modal.title", - value: "Receipt", - comment: "Button title for the receipt button") - static let subtitle = NSLocalizedString( - "pointOfSale.sendreceipt.modal.subtitle", - value: "Email", - comment: "Subtitle for the view where an email address should be entered when sending receipts") - static let buttonTitle = NSLocalizedString( - "pointOfSale.sendreceipt.modal.button.title", - value: "Send", - comment: "Button title for sending a receipt") - static let textfieldPlaceholder = NSLocalizedString( - "pointOfSale.sendreceipt.modal.textfield.placeholder", - value: "Enter an email", - comment: "Placeholder for the view where an email address should be entered when sending receipts") - } - struct Constants { - static let buttonSpacing: CGFloat = 12 - static let buttonPadding: CGFloat = 32 - static let buttonFont: POSFontStyle = .posBodyEmphasized - static let buttonCornerRadius: CGFloat = 8 - } -} diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift new file mode 100644 index 00000000000..dacedbea737 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSSendReceiptView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import class WordPressShared.EmailFormatValidator + +struct POSSendReceiptView: View { + @EnvironmentObject private var posModel: PointOfSaleAggregateModel + @State private var textFieldInput: String = "" + @State private var isLoading: Bool = false + @State private var errorMessage: String? + + @Binding private(set) var isShowingSendReceiptView: Bool + + private var isEmailValid: Bool { + EmailFormatValidator.validate(string: textFieldInput) + } + + var body: some View { + VStack(alignment: .center, spacing: 20) { + HStack { + Button(action: { + isShowingSendReceiptView = false + }, label: { + HStack { + Image(systemName: "chevron.left") + Text(Localization.emailReceiptNavigationText) + } + .font(.title) + .bold() + .foregroundColor(.primary) + }) + Spacer() + } + .buttonStyle(.plain) + .padding() + .disabled(isLoading) + + TextField(Localization.textfieldPlaceholder, text: $textFieldInput) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.none) + .autocorrectionDisabled() + .multilineTextAlignment(.center) + .font(POSFontStyle.posTitleRegular) + .focused() + .padding() + .onSubmit { + sendReceipt() + } + + if let errorMessage = errorMessage { + Text(errorMessage) + .font(POSFontStyle.posBodyRegular) + .foregroundColor(.red) + } + + Button(action: { + sendReceipt() + }, label: { + HStack(spacing: Constants.buttonSpacing) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(Color.posPrimaryText) + } else { + Text(Localization.buttonTitle) + .font(Constants.buttonFont) + } + } + .frame(maxWidth: .infinity) + }) + .padding(Constants.buttonPadding) + .frame(maxWidth: .infinity) + .foregroundColor(Color.posPrimaryTextInverted) + .background(isEmailValid ? Color.posPrimaryButtonBackground : Color.posBackgroundButtonDisabled) + .cornerRadius(Constants.buttonCornerRadius) + .contentShape(Rectangle()) + .disabled(isLoading) + + Spacer() + } + .padding() + .animation(.easeInOut, value: errorMessage) + .onChange(of: textFieldInput) { _ in + errorMessage = nil + } + } + + private func sendReceipt() { + Task { @MainActor in + guard isEmailValid else { + errorMessage = Localization.emailValidationErrorText + return + } + isLoading = true + do { + errorMessage = nil + try await posModel.sendReceipt(to: textFieldInput) + isShowingSendReceiptView = false + } catch { + errorMessage = Localization.sendReceiptErrorText + } + isLoading = false + } + } +} + +private extension POSSendReceiptView { + enum Constants { + static let buttonSpacing: CGFloat = 12 + static let buttonPadding: CGFloat = 32 + static let buttonFont: POSFontStyle = .posBodyEmphasized + static let buttonCornerRadius: CGFloat = 8 + } +} + +private extension POSSendReceiptView { + struct Localization { + static let buttonTitle = NSLocalizedString( + "pointOfSale.sendreceipt.button.title", + value: "Send", + comment: "Button title for sending a receipt") + static let textfieldPlaceholder = NSLocalizedString( + "pointOfSale.sendreceipt.textfield.placeholder", + value: "Type email", + comment: "Placeholder for the view where an email address should be entered when sending receipts") + static let sendReceiptErrorText = NSLocalizedString( + "pointOfSale.sendreceipt.sendReceiptErrorText", + value: "Error trying to send this email. Try again.", + comment: "Generic error message that is displayed when there's an error emailing a receipt.") + static let emailValidationErrorText = NSLocalizedString( + "pointOfSale.sendreceipt.emailValidationErrorText", + value: "Please enter a valid email.", + comment: "Error message that is displayed when an invalid email is used when emailing a receipt.") + static let emailReceiptNavigationText = NSLocalizedString( + "pointOfSale.sendreceipt.emailReceiptNavigationText", + value: "Email receipt", + comment: "Text that shows at the top of the receipts screen along the back button.") + } +} + +#if DEBUG +#Preview { + let posModel = PointOfSaleAggregateModel( + itemsController: PointOfSalePreviewItemsController(), + cardPresentPaymentService: CardPresentPaymentPreviewService(), + orderController: PointOfSalePreviewOrderController()) + POSSendReceiptView(isShowingSendReceiptView: .constant(true)) + .environmentObject(posModel) +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/SimpleProductCardView.swift b/WooCommerce/Classes/POS/Presentation/SimpleProductCardView.swift new file mode 100644 index 00000000000..fc6a336eecf --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/SimpleProductCardView.swift @@ -0,0 +1,67 @@ +import struct Yosemite.POSSimpleProduct +import SwiftUI + +struct SimpleProductCardView: View { + private let product: POSSimpleProduct + + @ScaledMetric private var scale: CGFloat = 1.0 + @Environment(\.dynamicTypeSize) var dynamicTypeSize + + init(product: POSSimpleProduct) { + self.product = product + } + + var body: some View { + HStack(spacing: Constants.cardSpacing) { + POSItemImageView(imageSource: product.productImageSource, + imageSize: Constants.productCardSize * scale, + scale: scale) + .frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize), + height: Constants.productCardSize * scale) + .clipped() + + DynamicHStack(spacing: Constants.textSpacing) { + Spacer().renderedIf(dynamicTypeSize.isAccessibilitySize) + Text(product.name) + .lineLimit(2) + .foregroundStyle(Color.posPrimaryText) + .multilineTextAlignment(.leading) + .font(Constants.itemNameFont) + Spacer() + Text(product.formattedPrice) + .foregroundStyle(Color.posPrimaryText) + .font(Constants.itemPriceFont) + Spacer().renderedIf(dynamicTypeSize.isAccessibilitySize) + } + .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) + .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) + Spacer() + } + .frame(maxWidth: .infinity, idealHeight: Constants.productCardSize * scale) + .background(Color.posSecondaryBackground) + .posItemCardBorderStyles() + } +} + +private extension SimpleProductCardView { + enum Constants { + static let productCardSize: CGFloat = 112 + static let maximumProductCardSize: CGFloat = Constants.productCardSize * 2 + static let cardSpacing: CGFloat = 0 + static let textSpacing: CGFloat = 8 + static let horizontalTextPadding: CGFloat = 32 + static let verticalTextPadding: CGFloat = 8 + static let itemNameFont: POSFontStyle = .posBodyEmphasized + static let itemPriceFont: POSFontStyle = .posBodyRegular + } +} + +#if DEBUG +#Preview { + SimpleProductCardView(product: POSSimpleProduct(id: UUID(), + name: "Product name", + formattedPrice: "$3.00", + productID: 123, + price: "3.00")) +} +#endif diff --git a/WooCommerce/Classes/POS/Presentation/TotalsView.swift b/WooCommerce/Classes/POS/Presentation/TotalsView.swift index a489cbb977c..19988ea5e5b 100644 --- a/WooCommerce/Classes/POS/Presentation/TotalsView.swift +++ b/WooCommerce/Classes/POS/Presentation/TotalsView.swift @@ -16,15 +16,17 @@ struct TotalsView: View { private var shouldShowTotalsFields: Bool { viewHelper.shouldShowTotalsFields(for: posModel.paymentState) } - @State private var isShowingPaymentsButtonSpacing: Bool = false - @State private var isShowingSendReceiptModal: Bool = false + + private var shouldShowCollectCashPaymentButton: Bool { + ServiceLocator.featureFlagService.isFeatureFlagEnabled(.acceptCashForPointOfSale) && + posModel.orderState != .syncing && + (posModel.paymentState == .idle || posModel.paymentState == .acceptingCard) + } @Environment(\.dynamicTypeSize) var dynamicTypeSize @Environment(\.colorScheme) var colorScheme - private var shouldShowSendReceiptButton: Bool { - ServiceLocator.featureFlagService.isFeatureFlagEnabled(.sendReceiptsForPointOfSale) - } + @State private var shouldShowCollectCashPayment: Bool = false var body: some View { HStack { @@ -48,7 +50,6 @@ struct TotalsView: View { .transition(.opacity) .background(cardReaderViewLayout.backgroundColor) .accessibilityShowsLargeContentViewer() - .minimumScaleFactor(0.1) .layoutPriority(1) } @@ -59,9 +60,19 @@ struct TotalsView: View { .opacity(viewHelper.shouldShowTotalsFields(for: posModel.paymentState) ? 1 : 0) .layoutPriority(2) } + Button(action: { + shouldShowCollectCashPayment = true + }, label: { + Text(Localization.cashPaymentButtonTitle) + .font(POSFontStyle.posBodyEmphasized) + .foregroundColor(.posPrimaryText) + .frame(height: Constants.buttonHeight) + }) + .buttonStyle(SecondaryButtonStyle()) + .padding(.horizontal, Constants.buttonHorizontalPadding) + .renderedIf(shouldShowCollectCashPaymentButton) } .animation(.default, value: posModel.cardPresentPaymentInlineMessage) - paymentsActionButtons Spacer() } .animation(.default, value: isShowingCardReaderStatus) @@ -77,15 +88,14 @@ struct TotalsView: View { isShowingTotalsFields = shouldShowTotalsFields } .onChange(of: shouldShowTotalsFields, perform: hideTotalsFieldsWithDelay) - .geometryGroupIfSupported() - .posModal(isPresented: $isShowingSendReceiptModal) { - POSSendReceiptModalView(sendReceipt: { email in - Task { @MainActor in - await posModel.sendReceipt(to: email) - } - }, isPresented: $isShowingSendReceiptModal) - .posModalSizing() + .fullScreenCover(isPresented: $shouldShowCollectCashPayment) { + if case .loaded(let total) = posModel.orderState { + PointOfSaleCollectCashView(orderTotal: total.orderTotal) + .matchedGeometryEffect(id: Constants.matchedGeometryCashId, + in: totalsFieldAnimation) + } } + .geometryGroupIfSupported() } private var backgroundColor: Color { @@ -212,62 +222,6 @@ private extension TotalsView { } private extension TotalsView { - private var newOrderButton: some View { - Button(action: { - posModel.startNewCart() - }, label: { - HStack(spacing: Constants.buttonSpacing) { - Text(Localization.newOrder) - .font(Constants.buttonFont) - } - .frame(minWidth: UIScreen.main.bounds.width / 2) - }) - .padding(Constants.buttonPadding) - .foregroundColor(Color.posPrimaryTextInverted) - .background(Color.posOverlayFillInverted) - .cornerRadius(Constants.buttonCornerRadius) - } - - private var sendReceiptButton: some View { - Button(action: { - isShowingSendReceiptModal = true - }, label: { - HStack(spacing: Constants.buttonSpacing) { - Text(Localization.sendReceipt) - .font(Constants.buttonFont) - } - .frame(minWidth: UIScreen.main.bounds.width / 2) - }) - .padding(Constants.buttonPadding) - .foregroundColor(Color.posPrimaryText) - .background(Color.clear) - .overlay { - RoundedRectangle(cornerRadius: Constants.buttonCornerRadius) - .stroke(Color.posPrimaryText, lineWidth: 1.0) - } - } - - @ViewBuilder - private var paymentsActionButtons: some View { - if posModel.paymentState == .cardPaymentSuccessful { - if isShowingPaymentsButtonSpacing { - Spacer().frame(height: Constants.paymentsButtonSpacing) - } - sendReceiptButton - .renderedIf(shouldShowSendReceiptButton) - newOrderButton - .onAppear { - isShowingPaymentsButtonSpacing = false - withAnimation(.default.delay(Constants.paymentsButtonButtonSpacingAnimationDelay)) { - isShowingPaymentsButtonSpacing = true - } - } - Spacer().frame(height: Constants.paymentsButtonSpacing) - } - else { - EmptyView() - } - } @ViewBuilder private var cardReaderView: some View { switch posModel.cardReaderConnectionStatus { @@ -362,9 +316,9 @@ private extension TotalsView { private extension TotalsView { enum Constants { static let pricesIdealWidth: CGFloat = 382 - static let buttonCornerRadius: CGFloat = 8 - static let verticalSpacing: CGFloat = 56 + static let buttonHeight: CGFloat = 56 + static let buttonHorizontalPadding: CGFloat = 48 static let totalsLineViewPadding: EdgeInsets = .init(top: 20, leading: 24, bottom: 20, trailing: 24) static let subtotalsVerticalSpacing: CGFloat = 8 @@ -381,16 +335,11 @@ private extension TotalsView { static let subtotalsShimmeringHeight: CGFloat = 36 static let totalShimmeringHeight: CGFloat = 40 - static let paymentsButtonSpacing: CGFloat = 80 - static let paymentsButtonButtonSpacingAnimationDelay: CGFloat = 0.3 - static let buttonSpacing: CGFloat = 12 - static let buttonPadding: CGFloat = 32 - static let buttonFont: POSFontStyle = .posBodyEmphasized - /// Used for synchronizing animations of shimmeringLine and textField static let matchedGeometrySubtotalId: String = "pos_totals_view_subtotal_matched_geometry_id" static let matchedGeometryTaxId: String = "pos_totals_view_tax_matched_geometry_id" static let matchedGeometryTotalId: String = "pos_totals_view_total_matched_geometry_id" + static let matchedGeometryCashId: String = "pos_totals_view_cash_matched_geometry_id" static let totalsFieldsHideAnimationDelay: CGFloat = 0.3 } @@ -408,14 +357,10 @@ private extension TotalsView { "pos.totalsView.taxes", value: "Taxes", comment: "Title for taxes amount field") - static let newOrder = NSLocalizedString( - "pos.totalsView.newOrder", - value: "New order", - comment: "Button title for new order button") - static let sendReceipt = NSLocalizedString( - "pos.totalsView.sendReceipt", - value: "Receipt", - comment: "Button title for the receipt button") + static let cashPaymentButtonTitle = NSLocalizedString( + "pos.totalsView.cash.button.title", + value: "Cash payment", + comment: "Title for the cash payment button title") } } diff --git a/WooCommerce/Classes/POS/Presentation/VariationCardView.swift b/WooCommerce/Classes/POS/Presentation/VariationCardView.swift new file mode 100644 index 00000000000..9e63b2dce87 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/VariationCardView.swift @@ -0,0 +1,75 @@ +import struct Yosemite.POSVariation +import SwiftUI + +struct VariationCardView: View { + private let variation: POSVariation + + @ScaledMetric private var scale: CGFloat = 1.0 + + init(variation: POSVariation) { + self.variation = variation + } + + var body: some View { + HStack(spacing: Constants.cardSpacing) { + POSItemImageView(imageSource: variation.productImageSource, + imageSize: Constants.productCardSize * scale, + scale: scale) + .frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize), + height: Constants.productCardSize * scale) + .clipped() + + VStack(alignment: .leading, spacing: Constants.textSpacing) { + Text(variation.name) + .lineLimit(2) + .foregroundStyle(Color.posPrimaryText) + .multilineTextAlignment(.leading) + .font(Constants.itemNameFont) + + Text(variation.formattedPrice) + .foregroundStyle(Color.posPrimaryText) + .font(Constants.itemPriceFont) + } + .padding(.horizontal, Constants.horizontalTextPadding * (1 / scale)) + .padding(.vertical, Constants.verticalTextPadding * (1 / scale)) + Spacer() + } + .frame(maxWidth: .infinity, idealHeight: Constants.productCardSize * scale) + .background(Color.posSecondaryBackground) + .posItemCardBorderStyles() + } +} + +private extension VariationCardView { + enum Constants { + static let productCardSize: CGFloat = 112 + static let maximumProductCardSize: CGFloat = Constants.productCardSize * 2 + static let cardSpacing: CGFloat = 0 + static let textSpacing: CGFloat = 8 + static let horizontalTextPadding: CGFloat = 32 + static let verticalTextPadding: CGFloat = 8 + static let itemNameFont: POSFontStyle = .posBodyEmphasized + static let itemPriceFont: POSFontStyle = .posBodyRegular + } +} + +#Preview("Variation without image") { + let variation = POSVariation(id: .init(), + name: "500ml, double shot", + formattedPrice: "$5.00", + price: "5.00", + productID: 134, + variationID: 256) + VariationCardView(variation: variation) +} + +#Preview("Variation with image") { + let variation = POSVariation(id: .init(), + name: "500ml, double shot", + formattedPrice: "$5.00", + price: "5.00", + productImageSource: "https://pd.w.org/2024/12/986762d0d4d4cf17.82435881-scaled.jpeg", + productID: 134, + variationID: 256) + VariationCardView(variation: variation) +} diff --git a/WooCommerce/Classes/POS/Utils/Color+WooCommercePOS.swift b/WooCommerce/Classes/POS/Utils/Color+WooCommercePOS.swift index d4259753a1a..662c3624819 100644 --- a/WooCommerce/Classes/POS/Utils/Color+WooCommercePOS.swift +++ b/WooCommerce/Classes/POS/Utils/Color+WooCommercePOS.swift @@ -120,6 +120,9 @@ extension Color { return .posGray } + static var posBackgroundButtonDisabled: Color { + return .init(red: 195.0 / 255.0, green: 196.0 / 255.0, blue: 199.0 / 255.0) + } } // MARK: - Non-adaptive colors diff --git a/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift b/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift index b386079f0cc..a046f1a149d 100644 --- a/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift +++ b/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift @@ -18,8 +18,10 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol { func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async { } - func sendReceipt(recipientEmail: String) async { } + func sendReceipt(recipientEmail: String) async throws { } func clearOrder() { } + + func collectCashPayment() async throws {} } #endif diff --git a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift index 385195c0d4b..1ecdd50009e 100644 --- a/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift +++ b/WooCommerce/Classes/POS/Utils/PreviewHelpers.swift @@ -2,11 +2,14 @@ import Foundation import protocol Yosemite.PointOfSaleItemServiceProtocol -import protocol Yosemite.POSDisplayableItem +import enum Yosemite.POSItem +import struct Yosemite.POSSimpleProduct import protocol Yosemite.POSOrderableItem import protocol Yosemite.OrderSyncProductTypeProtocol import struct Yosemite.OrderSyncProductInput import enum Yosemite.ProductType +import struct Yosemite.PagedItems +import struct Yosemite.POSVariableParentProduct import struct Yosemite.ProductBundleItem import struct Yosemite.OrderItem import Combine @@ -36,11 +39,15 @@ struct POSProductPreview: POSOrderableItem, Equatable { } final class PointOfSalePreviewItemService: PointOfSaleItemServiceProtocol { - func providePointOfSaleItems(pageNumber: Int) async throws -> [POSDisplayableItem] { - [] + func providePointOfSaleItems(pageNumber: Int) async throws -> PagedItems { + .init(items: [], hasMorePages: true) } - func providePointOfSaleItems() -> [POSDisplayableItem] { + func providePointOfSaleVariationItems(for parentProduct: POSVariableParentProduct, pageNumber: Int) async throws -> PagedItems { + .init(items: mockVariationItems, hasMorePages: true) + } + + func providePointOfSaleItems() -> [POSItem] { return mockItems } @@ -52,30 +59,68 @@ final class PointOfSalePreviewItemService: PointOfSaleItemServiceProtocol { } final class PointOfSalePreviewItemsController: PointOfSaleItemsControllerProtocol { - @Published var itemListState: ItemListState = .initialLoading - var itemListStatePublisher: any Publisher { $itemListState } - - var allItems: [POSDisplayableItem] = [] + @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, + itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])) + var itemsViewStatePublisher: any Publisher { $itemsViewState } func loadInitialItems() async { - itemListState = .loaded(mockItems) + itemsViewState = ItemsViewState(containerState: .content, itemsStack: ItemsStackState(root: .loaded(mockItems), + itemStates: [:])) } func loadNextItems() async { - itemListState = .loading(mockItems) + itemsViewState = ItemsViewState(containerState: .content, itemsStack: ItemsStackState(root: .loading(mockItems), + itemStates: [:])) } func reload() async { - itemListState = .loaded([]) + itemsViewState = ItemsViewState(containerState: .content, itemsStack: ItemsStackState(root: .loaded([]), + itemStates: [:])) + } + + func loadInitialChildItems(for parent: POSItem) async { + itemsViewState = ItemsViewState( + containerState: .content, + itemsStack: ItemsStackState( + root: .loading(mockItems), + itemStates: [parent: .loaded(mockVariationItems)] + ) + ) } } -private var mockItems: [POSDisplayableItem] { +private var mockItems: [POSItem] { return [ - POSProductPreview(id: UUID(), name: "Product 1", formattedPrice: "$1.00"), - POSProductPreview(id: UUID(), name: "Product 2", formattedPrice: "$2.00"), - POSProductPreview(id: UUID(), name: "Product 3", formattedPrice: "$3.00"), - POSProductPreview(id: UUID(), name: "Product 4", formattedPrice: "$4.00") + .simpleProduct(POSSimpleProduct(id: UUID(), name: "Product 1", formattedPrice: "$1.00", productID: 1, price: "1.00")), + .simpleProduct(POSSimpleProduct(id: UUID(), name: "Product 2", formattedPrice: "$2.00", productID: 2, price: "2.00")), + .simpleProduct(POSSimpleProduct(id: UUID(), name: "Product 3", formattedPrice: "$3.00", productID: 3, price: "3.00")), + .variableParentProduct( + .init( + id: .init(), + name: "Variable product 1", + productImageSource: nil, + productID: 5 + ) + ), + .simpleProduct(POSSimpleProduct(id: UUID(), name: "Product 4", formattedPrice: "$4.00", productID: 4, price: "4.00")) + ] +} + +private var mockVariationItems: [POSItem] { + [ + .variation(.init(id: UUID(), + name: "Variation 1", + formattedPrice: "$1.00", + price: "1.00", + productID: 134, + variationID: 256)), + .variation(.init(id: UUID(), + name: "Variation 2", + formattedPrice: "$2.00", + price: "2.00", + productID: 134, + variationID: 256)), ] } diff --git a/WooCommerce/Classes/ServiceLocator/PushNotesManager.swift b/WooCommerce/Classes/ServiceLocator/PushNotesManager.swift index 321a844c5f2..874cf608bbc 100644 --- a/WooCommerce/Classes/ServiceLocator/PushNotesManager.swift +++ b/WooCommerce/Classes/ServiceLocator/PushNotesManager.swift @@ -29,6 +29,10 @@ protocol PushNotesManager { /// var localNotificationUserResponses: AnyPublisher { get } + /// WordPress.com Device Identifier + /// + var deviceID: String? { get } + /// Resets the Badge Count. /// func resetBadgeCount(type: Note.Kind) diff --git a/WooCommerce/Classes/System/SessionManager.swift b/WooCommerce/Classes/System/SessionManager.swift index 6594db69a8c..3d33266ae12 100644 --- a/WooCommerce/Classes/System/SessionManager.swift +++ b/WooCommerce/Classes/System/SessionManager.swift @@ -209,12 +209,12 @@ final class SessionManager: SessionManagerProtocol { defaults[.numberOfTimesProductCreationAISurveySuggested] = nil defaults[.didStartProductCreationAISurvey] = nil defaults[.themesPendingInstall] = nil - defaults[.siteIDPendingStoreSwitch] = nil - defaults[.expectedStoreNamePendingStoreSwitch] = nil + defaults[.hiddenStoreIDs] = nil defaults[.blazeNoCampaignReminderOpened] = nil defaults[.blazeAbandonedCampaignCreationReminderOpened] = nil defaults[.blazeSelectedCampaignObjective] = nil defaults[.wpcomSiteSuspended] = nil + defaults[.tapToPayAwarenessMomentFirstLaunchCompleted] = nil resetTimestampsValues() imageCache.clearCache() } diff --git a/WooCommerce/Classes/System/WooConstants.swift b/WooCommerce/Classes/System/WooConstants.swift index c1ac771c1a0..59e9d3297d3 100644 --- a/WooCommerce/Classes/System/WooConstants.swift +++ b/WooCommerce/Classes/System/WooConstants.swift @@ -360,6 +360,7 @@ extension WooConstants { case customFieldsProductLearnMore = "https://woocommerce.com/document/custom-product-fields/" case customFieldsOrderLearnMore = "https://woocommerce.com/document/managing-orders/view-edit-or-add-an-order/#custom-fields" + case hsTariffURL = "https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#section-29" /// Returns the URL version of the receiver /// diff --git a/WooCommerce/Classes/Tools/ImageService/DefaultImageService.swift b/WooCommerce/Classes/Tools/ImageService/DefaultImageService.swift index 5228461df8a..0d278dfedf5 100644 --- a/WooCommerce/Classes/Tools/ImageService/DefaultImageService.swift +++ b/WooCommerce/Classes/Tools/ImageService/DefaultImageService.swift @@ -32,7 +32,7 @@ struct DefaultImageService: ImageService { return options } - init(imageCache: ImageCache = ImageCache.default, + init(imageCache: ImageCache = ImageCache.optimizedCache, imageDownloader: ImageDownloader = Kingfisher.ImageDownloader.default) { self.imageCache = imageCache self.imageDownloader = imageDownloader @@ -85,4 +85,19 @@ struct DefaultImageService: ImageService { } } } + + func clearMemoryCache() { + imageCache.clearMemoryCache() + } +} + +extension ImageCache { + /// Cache with stricter limit to optimize memory usage. + /// + static var optimizedCache: ImageCache { + let cache = ImageCache.default + cache.memoryStorage.config.totalCostLimit = 50 * 1024 * 1024 // 50MB + cache.memoryStorage.config.countLimit = 25 + return cache + } } diff --git a/WooCommerce/Classes/Tools/ImageService/ImageService.swift b/WooCommerce/Classes/Tools/ImageService/ImageService.swift index 9328a106106..2cd8aaa603b 100644 --- a/WooCommerce/Classes/Tools/ImageService/ImageService.swift +++ b/WooCommerce/Classes/Tools/ImageService/ImageService.swift @@ -33,6 +33,9 @@ protocol ImageService { placeholder: UIImage?, progressBlock: ImageDownloadProgressBlock?, completion: ImageDownloadCompletion?) + + /// Clears memory cache to reduce memory usage. + func clearMemoryCache() } // MARK: - Errors diff --git a/WooCommerce/Classes/Tools/InfiniteScroll/AsyncPaginationTracker.swift b/WooCommerce/Classes/Tools/InfiniteScroll/AsyncPaginationTracker.swift new file mode 100644 index 00000000000..1972994353f --- /dev/null +++ b/WooCommerce/Classes/Tools/InfiniteScroll/AsyncPaginationTracker.swift @@ -0,0 +1,132 @@ +import Yosemite + +/// Async/await version of `PaginationTracker`, consider renaming `PaginationTracker` as deprecated and this class to `PaginationTracker`. +/// Keeps track of the pagination for API syncing to support infinite scroll and pull-to-refresh. +final class AsyncPaginationTracker { + typealias SyncFunction = (_ pageNumber: Int) async throws -> Bool + + /// State of loading the next page in `ensureNextPageIsSynced`. + enum NextPageSyncState { + case syncing + case synced + case noNextPage + } + + /// Default pagination settings. + enum Defaults { + static let pageFirstIndex = Store.Default.firstPageNumber + } + + /// The index of the first page in the API. So far, both Woo and WP.com API have the first page index at 1. + private let pageFirstIndex: Int + + /// Indexes of the pages that have been successfully synced. + private var pagesSynced = IndexSet() + + /// Indexes of the pages being currently synced. + private var pagesBeingSynced = IndexSet() + + /// Whether there might be more pages to fetch from the API, set by the sync function. + private(set) var hasNextPage: Bool = true + + /// Returns the highest page number that has been successfully synced, if any. + private var highestPageSynced: Int? { + pagesSynced.max() + } + + /// Returns the highest page number that is currently being synced, if any. + private var highestPageBeingSynced: Int? { + pagesBeingSynced.max() + } + + /// Designated Initializer + init(pageFirstIndex: Int = Defaults.pageFirstIndex) { + self.pageFirstIndex = pageFirstIndex + } + + /// Should be called whenever a scroll position is approaching the end of the list for infinite scroll support. + /// This method will: + /// 1. Proceed only if there is next page to sync. + /// 2. Verify if the next page isn't currently being synced. + /// 3. Proceed syncing the next page. + func ensureNextPageIsSynced(syncFunction: @escaping SyncFunction) async throws -> NextPageSyncState { + guard hasNextPage else { + return .noNextPage + } + + let nextPage = (highestPageSynced ?? pageFirstIndex - 1) + 1 + guard !isPageBeingSynced(pageNumber: nextPage) else { + return .syncing + } + do { + try await sync(pageNumber: nextPage, syncFunction: syncFunction) + return .synced + } catch { + throw error + } + } + + /// Resets internal states and resyncs the first page of results. + /// + func resync(syncFunction: @escaping SyncFunction) async throws { + resetInternalState() + try await syncFirstPage(syncFunction: syncFunction) + } + + /// Syncs the first page of results. + /// + func syncFirstPage(syncFunction: @escaping SyncFunction) async throws { + try await sync(pageNumber: pageFirstIndex, syncFunction: syncFunction) + } +} + +// MARK: - Syncing Core +// +private extension AsyncPaginationTracker { + /// Syncs a given page number. + func sync(pageNumber: Int, syncFunction: @escaping SyncFunction) async throws { + markAsBeingSynced(pageNumber: pageNumber) + + do { + defer { + unmarkAsBeingSynced(pageNumber: pageNumber) + } + let hasNextPage = try await syncFunction(pageNumber) + self.hasNextPage = hasNextPage + markAsSynced(pageNumber: pageNumber) + } catch { + throw error + } + } +} + +// MARK: - Private Methods +// +private extension AsyncPaginationTracker { + /// Resets all of the internal structures. + func resetInternalState() { + pagesBeingSynced.removeAll() + pagesSynced.removeAll() + hasNextPage = true + } + + /// Indicates if a given page number is currently being synced. + func isPageBeingSynced(pageNumber: Int) -> Bool { + return pagesBeingSynced.contains(pageNumber) + } + + /// Marks the specified page number as synced with the current date. + func markAsSynced(pageNumber: Int) { + pagesSynced.insert(pageNumber) + } + + /// Marks the specified page number as being synced. + func markAsBeingSynced(pageNumber: Int) { + pagesBeingSynced.insert(pageNumber) + } + + /// Removes the specified page number from the "In Sync" collection. + func unmarkAsBeingSynced(pageNumber: Int) { + pagesBeingSynced.remove(pageNumber) + } +} diff --git a/WooCommerce/Classes/Tools/Location/LocationService.swift b/WooCommerce/Classes/Tools/Location/LocationService.swift new file mode 100644 index 00000000000..525a8df2e38 --- /dev/null +++ b/WooCommerce/Classes/Tools/Location/LocationService.swift @@ -0,0 +1,72 @@ +import Foundation +import CoreLocation + +protocol LocationServiceProtocol { + func requestPermission() + func observePermissionChanges(_ onChange: @escaping (LocationAuthorizationStatus) -> Void) + func stopObservingPermissionChanges() + var authorizationStatus: LocationAuthorizationStatus { get } +} + +enum LocationAuthorizationStatus { + case notDetermined + case denied + case authorized +} + +final class LocationService: NSObject, LocationServiceProtocol { + private let locationManager: CLLocationManager + private var onStatusChange: ((LocationAuthorizationStatus) -> Void)? + + init(locationManager: CLLocationManager = CLLocationManager()) { + self.locationManager = locationManager + super.init() + locationManager.delegate = self + } + + func requestPermission() { + let status = locationManager.authorizationStatus + + guard status == .notDetermined else { + return + } + + locationManager.requestWhenInUseAuthorization() + } + + func observePermissionChanges(_ onChange: @escaping (LocationAuthorizationStatus) -> Void) { + onStatusChange = onChange + } + + func stopObservingPermissionChanges() { + onStatusChange = nil + } + + var authorizationStatus: LocationAuthorizationStatus { + let status = locationManager.authorizationStatus + return authorizationStatus(from: status) + } +} + +extension LocationService: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + onStatusChange?(authorizationStatus(from: manager.authorizationStatus)) + } +} + +// MARK: - Mapping Status + +private extension LocationService { + func authorizationStatus(from status: CLAuthorizationStatus) -> LocationAuthorizationStatus { + switch status { + case .authorizedWhenInUse, .authorizedAlways: + return .authorized + case .restricted, .denied: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .denied + } + } +} diff --git a/WooCommerce/Classes/Tools/Zendesk/ZendeskManager.swift b/WooCommerce/Classes/Tools/Zendesk/ZendeskManager.swift index 5b0902c3f30..db4742e50ef 100644 --- a/WooCommerce/Classes/Tools/Zendesk/ZendeskManager.swift +++ b/WooCommerce/Classes/Tools/Zendesk/ZendeskManager.swift @@ -110,7 +110,7 @@ struct ZendeskProvider { final class ZendeskManager: NSObject, ZendeskManagerProtocol { /// Indicates if Zendesk is Enabled (or not) /// - private (set) var zendeskEnabled = false { + private(set) var zendeskEnabled = false { didSet { DDLogInfo("Zendesk Enabled: \(zendeskEnabled)") } diff --git a/WooCommerce/Classes/View Modifiers/View+RoundedBorder.swift b/WooCommerce/Classes/View Modifiers/View+RoundedBorder.swift index ccdc9c8f8b7..06759ccd2ac 100644 --- a/WooCommerce/Classes/View Modifiers/View+RoundedBorder.swift +++ b/WooCommerce/Classes/View Modifiers/View+RoundedBorder.swift @@ -11,7 +11,7 @@ struct RoundedBorder: ViewModifier { content .overlay { RoundedRectangle(cornerRadius: cornerRadius) - .stroke(style: StrokeStyle(lineWidth: lineWidth, dash: dashed ? [Layout.dashLength] : [])) + .strokeBorder(style: StrokeStyle(lineWidth: lineWidth, dash: dashed ? [Layout.dashLength] : [])) .foregroundStyle(lineColor) } } diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalError.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalError.swift index 21959d7893d..45142c207d5 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalError.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalError.swift @@ -40,6 +40,7 @@ final class CardPresentModalError: CardPresentPaymentsModalViewModel { init(errorDescription: String?, transactionType: CardPresentTransactionType, image: UIImage = .paymentErrorImage, + requiresFallbackPaymentMethod: Bool = false, tryAgainAction: @escaping () -> Void, emailReceiptAction: @escaping () -> Void, dismissCompletion: @escaping () -> Void) { @@ -47,7 +48,8 @@ final class CardPresentModalError: CardPresentPaymentsModalViewModel { self.bottomTitle = errorDescription self.image = image self.primaryButtonTitle = Localization.tryAgain(transactionType: transactionType) - self.auxiliaryButtonTitle = Localization.noThanks(transactionType: transactionType) + self.auxiliaryButtonTitle = Localization.dismiss(transactionType: transactionType, + requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.tryAgainAction = tryAgainAction self.emailReceiptAction = emailReceiptAction self.dismissCompletion = dismissCompletion @@ -100,21 +102,43 @@ extension CardPresentModalError { } } - static func noThanks(transactionType: CardPresentTransactionType) -> String { + static func dismiss(transactionType: CardPresentTransactionType, requiresFallbackPaymentMethod: Bool) -> String { switch transactionType { case .collectPayment: - return NSLocalizedString( - "Back to Order", - comment: "Button to dismiss modal overlay. Presented to users after collecting a payment fails" - ) + if requiresFallbackPaymentMethod { + return Localization.tryAnotherPaymentMethod + } else { + return Localization.backToOrder + } case .refund: - return NSLocalizedString( - "Close", - comment: "Button to dismiss modal overlay. Presented to users after refunding a payment fails" - ) + return Localization.close } } + static let tryAnotherPaymentMethod = NSLocalizedString( + "cardPresentPaymentsModal.error.tryAnotherPaymentMethod", + value: "Try Another Payment Method", + comment: "Button to dismiss modal overlay and try another payment method. Presented to users after collecting a payment fails" + ) + + static let backToOrder = NSLocalizedString( + "cardPresentPaymentsModal.error.backToOrder", + value: "Back to Order", + comment: "Button to dismiss modal overlay. Presented to users after collecting a payment fails" + ) + + static let dismiss = NSLocalizedString( + "cardPresentPaymentsModal.error.dismiss", + value: "Dismiss", + comment: "Button to dismiss. Presented to users after collecting a payment fails" + ) + + static let close = NSLocalizedString( + "cardPresentPaymentsModal.error.close", + value: "Close", + comment: "Button to dismiss modal overlay. Presented to users after refunding a payment fails" + ) + static let receiptMessage = NSLocalizedString( "cardPresentPaymentsModal.error.receiptMessage", value: "A receipt has been sent to %1$@", diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorEmailSent.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorEmailSent.swift index e8e9db62a12..d5be2aa480d 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorEmailSent.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorEmailSent.swift @@ -40,13 +40,15 @@ final class CardPresentModalErrorEmailSent: CardPresentPaymentsModalViewModel { transactionType: CardPresentTransactionType, image: UIImage = .paymentErrorImage, email: String, + requiresFallbackPaymentMethod: Bool = false, tryAgainAction: @escaping () -> Void, dismissCompletion: @escaping () -> Void) { self.topTitle = CardPresentModalError.Localization.paymentFailed(transactionType: transactionType) self.bottomTitle = errorDescription self.image = image self.primaryButtonTitle = CardPresentModalError.Localization.tryAgain(transactionType: transactionType) - self.secondaryButtonTitle = CardPresentModalError.Localization.noThanks(transactionType: transactionType) + self.secondaryButtonTitle = CardPresentModalError.Localization.dismiss(transactionType: transactionType, + requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.tryAgainAction = tryAgainAction self.dismissCompletion = dismissCompletion diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorWithoutEmail.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorWithoutEmail.swift index d8cc2881f1d..5b3dde4a897 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorWithoutEmail.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalErrorWithoutEmail.swift @@ -37,13 +37,15 @@ final class CardPresentModalErrorWithoutEmail: CardPresentPaymentsModalViewModel init(errorDescription: String?, transactionType: CardPresentTransactionType, image: UIImage = .paymentErrorImage, + requiresFallbackPaymentMethod: Bool = false, tryAgainAction: @escaping () -> Void, dismissCompletion: @escaping () -> Void) { self.topTitle = CardPresentModalError.Localization.paymentFailed(transactionType: transactionType) self.bottomTitle = errorDescription self.image = image self.primaryButtonTitle = CardPresentModalError.Localization.tryAgain(transactionType: transactionType) - self.secondaryButtonTitle = CardPresentModalError.Localization.noThanks(transactionType: transactionType) + self.secondaryButtonTitle = CardPresentModalError.Localization.dismiss(transactionType: transactionType, + requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.tryAgainAction = tryAgainAction self.dismissCompletion = dismissCompletion } diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationPreAlert.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationPreAlert.swift new file mode 100644 index 00000000000..909ceed9467 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationPreAlert.swift @@ -0,0 +1,62 @@ +import UIKit + +/// Modal presented before requesting location permission +/// +final class CardPresentModalLocationPreAlert: CardPresentPaymentsModalViewModel { + /// Called when continue button is tapped + private let requestPermission: () -> Void + + let textMode: PaymentsModalTextMode = .fullInfo + let actionsMode: PaymentsModalActionsMode = .oneAction + let topTitle: String = Localization.title + let topSubtitle: String? = nil + let image: UIImage = .cardReaderLocationImage + let primaryButtonTitle: String? = Localization.continueButton + let secondaryButtonTitle: String? = nil + let auxiliaryButtonTitle: String? = nil + let bottomTitle: String? = Localization.subtitle + let bottomSubtitle: String? = Localization.settings + var accessibilityLabel: String? { + return topTitle + (bottomTitle ?? "") + (bottomSubtitle ?? "") + } + + init(requestPermission: @escaping () -> Void) { + self.requestPermission = requestPermission + } + + func didTapPrimaryButton(in viewController: UIViewController?) { + requestPermission() + } + + func didTapSecondaryButton(in viewController: UIViewController?) {} + + func didTapAuxiliaryButton(in viewController: UIViewController?) {} +} + +private extension CardPresentModalLocationPreAlert { + enum Localization { + static let title = NSLocalizedString( + "cardPresentPayment.locationPreAlert.title", + value: "Enable location services on the next screen to allow payments.", + comment: "A title explaining why location services are needed to make a payment" + ) + + static let subtitle = NSLocalizedString( + "cardPresentPayment.locationPreAlert.subtitle", + value: "Location services permission is required to reduce fraud, prevent disputes, and ensure secure payments.", + comment: "A subtitle explaining why location services are needed to make a payment" + ) + + static let settings = NSLocalizedString( + "cardPresentPayment.locationPreAlert.settingsNotice", + value: "You can change this option later in the Settings app.", + comment: "A notice at the bottom explaining that location services can be changed in the Settings app later" + ) + + static let continueButton = NSLocalizedString( + "cardPresentPayment.locationPreAlert.continueButton", + value: "Continue", + comment: "A title for CTA to present native location permission alert" + ) + } +} diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationRequired.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationRequired.swift new file mode 100644 index 00000000000..f3473268553 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalLocationRequired.swift @@ -0,0 +1,67 @@ +import UIKit + +/// Modal presented when location permission is denied +/// +final class CardPresentModalLocationRequired: CardPresentPaymentsModalViewModel { + private let dismiss: () -> Void + + let textMode: PaymentsModalTextMode = .fullInfo + let actionsMode: PaymentsModalActionsMode = .twoAction + let topTitle: String = Localization.title + let topSubtitle: String? = nil + let image: UIImage = .cardReaderLocationImage + let primaryButtonTitle: String? = Localization.openSettings + let secondaryButtonTitle: String? = Localization.dismiss + let auxiliaryButtonTitle: String? = nil + let bottomTitle: String? = Localization.subtitle + let bottomSubtitle: String? = nil + var accessibilityLabel: String? { + return topTitle + (bottomTitle ?? "") + } + + init(dismiss: @escaping () -> Void) { + self.dismiss = dismiss + } + + func didTapPrimaryButton(in viewController: UIViewController?) { + guard let targetURL = URL(string: UIApplication.openSettingsURLString) else { + return + } + UIApplication.shared.open(targetURL) + } + + func didTapSecondaryButton(in viewController: UIViewController?) { + viewController?.dismiss(animated: true) + dismiss() + } + + func didTapAuxiliaryButton(in viewController: UIViewController?) {} +} + +private extension CardPresentModalLocationRequired { + enum Localization { + static let title = NSLocalizedString( + "cardPresentPayment.locationRequired.title", + value: "Enable location services in device settings to allow payments.", + comment: "A title explaining the requirement of location services for making a payment" + ) + + static let subtitle = NSLocalizedString( + "cardPresentPayment.locationRequired.subtitle", + value: "Location services permission is required to reduce fraud, prevent disputes, and ensure secure payments.", + comment: "A subtitle explaining why location services are needed to make a payment" + ) + + static let openSettings = NSLocalizedString( + "cardPresentPayment.locationRequired.openSettings", + value: "Open Device Settings", + comment: "Opens iOS's Device Settings for the app" + ) + + static let dismiss = NSLocalizedString( + "cardPresentPayment.locationRequired.dismiss", + value: "Dismiss", + comment: "Dismisses the location alert" + ) + } +} diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableError.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableError.swift index 291c0663af8..894331cade1 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableError.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableError.swift @@ -23,7 +23,7 @@ final class CardPresentModalNonRetryableError: CardPresentPaymentsModalViewModel let image: UIImage - let primaryButtonTitle: String? = Localization.dismiss + let primaryButtonTitle: String? let secondaryButtonTitle: String? = CardPresentModalError.Localization.emailReceipt @@ -44,11 +44,13 @@ final class CardPresentModalNonRetryableError: CardPresentPaymentsModalViewModel init(amount: String, errorDescription: String?, image: UIImage = .paymentErrorImage, + requiresFallbackPaymentMethod: Bool = false, onDismiss: @escaping () -> Void, emailReceiptAction: @escaping () -> Void) { self.amount = amount self.bottomTitle = errorDescription self.image = image + self.primaryButtonTitle = Localization.dismiss(requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.onDismiss = onDismiss self.emailReceiptAction = emailReceiptAction } @@ -86,9 +88,12 @@ extension CardPresentModalNonRetryableError { comment: "Error message. Presented to users after collecting a payment fails" ) - static let dismiss = NSLocalizedString( - "Dismiss", - comment: "Button to dismiss. Presented to users after collecting a payment fails" - ) + static func dismiss(requiresFallbackPaymentMethod: Bool) -> String { + if requiresFallbackPaymentMethod { + return CardPresentModalError.Localization.tryAnotherPaymentMethod + } else { + return CardPresentModalError.Localization.dismiss + } + } } } diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorEmailSent.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorEmailSent.swift index 09684355ec1..62c965c41ac 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorEmailSent.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorEmailSent.swift @@ -20,7 +20,7 @@ final class CardPresentModalNonRetryableErrorEmailSent: CardPresentPaymentsModal let image: UIImage - let primaryButtonTitle: String? = CardPresentModalNonRetryableError.Localization.dismiss + let primaryButtonTitle: String? let secondaryButtonTitle: String? = nil @@ -45,10 +45,12 @@ final class CardPresentModalNonRetryableErrorEmailSent: CardPresentPaymentsModal errorDescription: String?, image: UIImage = .paymentErrorImage, email: String, + requiresFallbackPaymentMethod: Bool = false, onDismiss: @escaping () -> Void) { self.amount = amount self.bottomTitle = errorDescription self.image = image + self.primaryButtonTitle = CardPresentModalNonRetryableError.Localization.dismiss(requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.onDismiss = onDismiss let formattedMessage = String(format: CardPresentModalError.Localization.receiptMessage, email) diff --git a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorWithoutEmail.swift b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorWithoutEmail.swift index 14dae486d42..cec80a82d3e 100644 --- a/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorWithoutEmail.swift +++ b/WooCommerce/Classes/ViewModels/CardPresentPayments/CardPresentModalNonRetryableErrorWithoutEmail.swift @@ -20,8 +20,7 @@ final class CardPresentModalNonRetryableErrorWithoutEmail: CardPresentPaymentsMo let image: UIImage - let primaryButtonTitle: String? = CardPresentModalNonRetryableError.Localization.dismiss - + let primaryButtonTitle: String? let secondaryButtonTitle: String? = nil let auxiliaryButtonTitle: String? = nil @@ -41,10 +40,12 @@ final class CardPresentModalNonRetryableErrorWithoutEmail: CardPresentPaymentsMo init(amount: String, errorDescription: String?, image: UIImage = .paymentErrorImage, + requiresFallbackPaymentMethod: Bool = false, onDismiss: @escaping () -> Void) { self.amount = amount self.bottomTitle = errorDescription self.image = image + self.primaryButtonTitle = CardPresentModalNonRetryableError.Localization.dismiss(requiresFallbackPaymentMethod: requiresFallbackPaymentMethod) self.onDismiss = onDismiss } diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift index a2fa4b19b5c..6661c7a78b6 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift @@ -936,20 +936,14 @@ extension OrderDetailsViewModel { /// We need to set it back to pending order when collecting payment to trigger all the related notifications when payment turns to failed again. /// func markOrderPaymentPending() { - guard order.status != .pending else { + guard order.status != .pending, featureFlagService.isFeatureFlagEnabled(.sendReceiptAfterPayment) else { return } - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment { [weak self] isEligible in - guard isEligible, let self else { - return - } - - let action = OrderAction.updateOrderStatus(siteID: order.siteID, - orderID: order.orderID, - status: .pending, onCompletion: { _ in }) - stores.dispatch(action) - } + let action = OrderAction.updateOrderStatus(siteID: order.siteID, + orderID: order.orderID, + status: .pending, onCompletion: { _ in }) + stores.dispatch(action) } } diff --git a/WooCommerce/Classes/ViewModels/Order Details/Receipts/ReceiptEligibilityUseCase.swift b/WooCommerce/Classes/ViewModels/Order Details/Receipts/ReceiptEligibilityUseCase.swift index b01744cf770..4e9cc488762 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/Receipts/ReceiptEligibilityUseCase.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/Receipts/ReceiptEligibilityUseCase.swift @@ -3,20 +3,24 @@ import Experiments protocol ReceiptEligibilityUseCaseProtocol { func isEligibleForBackendReceipts(onCompletion: @escaping (Bool) -> Void) - func isEligibleSendingReceiptAfterPayment(onCompletion: @escaping (Bool) -> Void) + func isEligibleForSuccessfulPaymentEmailReceipts(onCompletion: @escaping (Bool) -> Void) + func isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: String, onCompletion: @escaping (Bool) -> Void) } final class ReceiptEligibilityUseCase: ReceiptEligibilityUseCaseProtocol { private let stores: StoresManager private let featureFlagService: FeatureFlagService + private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol private var siteID: Int64 { stores.sessionManager.defaultStoreID ?? 0 } init(stores: StoresManager = ServiceLocator.stores, + cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.stores = stores + self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding self.featureFlagService = featureFlagService } @@ -44,11 +48,53 @@ final class ReceiptEligibilityUseCase: ReceiptEligibilityUseCaseProtocol { stores.dispatch(action) } - func isEligibleSendingReceiptAfterPayment(onCompletion: @escaping (Bool) -> Void) { + /// Returns true if Point of Sale allows sending successful payment email receipts via the API. + /// WooCommerce 9.5 allows to attach a customer email after payment is made and send email receipt via the API. + /// + func isEligibleForPointOfSaleReceipts(onCompletion: @escaping (Bool) -> Void) { + guard featureFlagService.isFeatureFlagEnabled(.sendReceiptsForPointOfSale) else { + onCompletion(false) + return + } + + Task { @MainActor in + let isWooCommerceSupported = await isPluginSupported(Constants.wcPluginName, + minimumVersion: Constants.PointOfSaleReceipts.wcPluginMinimumVersion) + onCompletion(isWooCommerceSupported) + } + } + + /// Returns true if In Person Payments allows sending successful payment email receipts via the API. + /// WooCommerce 9.5 allows to attach a customer email after payment is made and send email receipt via the API. + /// + func isEligibleForSuccessfulPaymentEmailReceipts(onCompletion: @escaping (Bool) -> Void) { + guard featureFlagService.isFeatureFlagEnabled(.sendReceiptAfterPayment) else { + return onCompletion(false) + } + + Task { @MainActor in + let isWooCommerceSupported = await isPluginSupported(Constants.wcPluginName, + minimumVersion: Constants.PointOfSaleReceipts.wcPluginMinimumVersion) + onCompletion(isWooCommerceSupported) + } + } + + /// Returns true if In Person Payments allows sending failed payment email receipts via the API. + /// WooCommerce 9.5 allows to attach a customer email after payment is made and send email receipt via the API. + /// WooCommerc 9.5 automatically sends failure receipt after the order fails if the customer email is attached to the order. + /// WooPayments 8.6 aligns the app with the web and automatically sets the order as failed when the payment processing fails. + /// Stripe Gateway doesn't automatically set the order to failed therefore the functionality is not supported. + /// + func isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: String, onCompletion: @escaping (Bool) -> Void) { guard featureFlagService.isFeatureFlagEnabled(.sendReceiptAfterPayment) else { return onCompletion(false) } + guard paymentGatewayID == Constants.ReceiptAfterPayment.woocommercePaymentsGatewayID else { + onCompletion(false) + return + } + Task { @MainActor in async let isWooCommerceSupported = isPluginSupported(Constants.wcPluginName, minimumVersion: Constants.ReceiptAfterPayment.wcPluginMinimumVersion) @@ -101,7 +147,11 @@ private extension ReceiptEligibilityUseCase { enum ReceiptAfterPayment { static let wcPluginMinimumVersion = "9.5.0" static let wcPayPluginMinimumVersion = "8.6.0" + static let woocommercePaymentsGatewayID = "woocommerce-payments" } + enum PointOfSaleReceipts { + static let wcPluginMinimumVersion = "9.5.0" + } } } diff --git a/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift b/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift index ab3421031bb..60c4ba086db 100644 --- a/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift +++ b/WooCommerce/Classes/ViewModels/ProductDetailsCellViewModel.swift @@ -2,48 +2,6 @@ import Foundation import Yosemite import WooFoundation -// MARK: - View Model for a Variation Attribute -// -struct VariationAttributeViewModel: Equatable { - - /// Attribute name - /// - let name: String - - /// Attribute value - /// - let value: String? - - /// Returns the attribute value, or "Any \(name)" if the attribute value is nil or empty - /// - var nameOrValue: String { - guard let value = value, value.isNotEmpty else { - return String(format: Localization.anyAttributeFormat, name) - } - return value - } - - init(name: String, value: String? = nil) { - self.name = name - self.value = value - } - - init(orderItemAttribute: OrderItemAttribute) { - self.init(name: orderItemAttribute.name, value: orderItemAttribute.value) - } - - init(productVariationAttribute: ProductVariationAttribute) { - self.init(name: productVariationAttribute.name, value: productVariationAttribute.option) - } -} - -extension VariationAttributeViewModel { - enum Localization { - static let anyAttributeFormat = - NSLocalizedString("Any %1$@", comment: "Format of a product variation attribute description where the attribute is set to any value.") - } -} - // MARK: - View Model for a product details cell // diff --git a/WooCommerce/Classes/ViewRelated/AppCoordinator.swift b/WooCommerce/Classes/ViewRelated/AppCoordinator.swift index 2549a3c4293..55ce1a42176 100644 --- a/WooCommerce/Classes/ViewRelated/AppCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/AppCoordinator.swift @@ -24,6 +24,7 @@ final class AppCoordinator { private var storePickerCoordinator: StorePickerCoordinator? private var authStatesSubscription: AnyCancellable? + private var localNotificationResponsesSubscription: AnyCancellable? private var isLoggedIn: Bool = false private let themeInstaller: ThemeInstaller @@ -96,6 +97,9 @@ final class AppCoordinator { self.isLoggedIn = isLoggedIn } + localNotificationResponsesSubscription = pushNotesManager.localNotificationUserResponses.sink { [weak self] response in + self?.handleLocalNotificationResponse(response) + } updateSitePropertiesIfNeeded() } } @@ -117,6 +121,20 @@ private extension AppCoordinator { stores.dispatch(action) } } + + func handleLocalNotificationResponse(_ response: UNNotificationResponse) { + let identifier = response.notification.request.identifier + + let userInfo = response.notification.request.content.userInfo + guard response.actionIdentifier != UNNotificationDismissActionIdentifier else { + analytics.track(event: .LocalNotification.dismissed(type: LocalNotification.Scenario.identifierForAnalytics(identifier), + userInfo: userInfo)) + return + } + + analytics.track(event: .LocalNotification.tapped(type: LocalNotification.Scenario.identifierForAnalytics(identifier), + userInfo: userInfo)) + } } // MARK: Theme install diff --git a/WooCommerce/Classes/ViewRelated/Blaze/CampaignCreation/BlazeCampaignCreationFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Blaze/CampaignCreation/BlazeCampaignCreationFormViewModel.swift index a65d4ae7464..96eeb75f930 100644 --- a/WooCommerce/Classes/ViewRelated/Blaze/CampaignCreation/BlazeCampaignCreationFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Blaze/CampaignCreation/BlazeCampaignCreationFormViewModel.swift @@ -356,6 +356,7 @@ final class BlazeCampaignCreationFormViewModel: ObservableObject { } } catch { DDLogError("⛔️ Error fetching Blaze AI suggestions: \(error)") + analytics.track(event: .Blaze.CreationForm.suggestionLoadingFailed(error: error)) self.error = .failedToLoadAISuggestions } diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift index 821509b3b45..f7238130a75 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderConnectionController.swift @@ -4,6 +4,7 @@ import UIKit import Storage import SwiftUI import Yosemite +import Experiments /// Facilitates connecting to a card reader /// @@ -37,20 +38,31 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { /// case searching + /// Requests location permission during the connection process + /// + case requestLocationPermission + /// Attempting to connect to a card reader. The completion passed to `searchAndConnect` /// will be called with a `success` `Bool` `True` result if successful, after which the view controller /// passed to `searchAndConnect` will be dereferenced and the state set to `idle` /// case connectToReader + /// Connection to a card reader is in progress. + /// `educationInProgress` is `true` if the merchant education is in progress. + /// + case connecting(educationInProgress: Bool) + /// A failure occurred while connecting. The search may continue or be canceled. At this time we /// do not present the detailed error from the service. + /// `educationInProgress` is `true` if the merchant education is in progress. /// - case connectingFailed(Error) + case connectingFailed(error: Error, educationInProgress: Bool) /// A mandatory update is being installed + /// `educationInProgress` is `true` if the merchant education is in progress /// - case updating(progress: Float) + case updating(progress: Float, educationInProgress: Bool) /// User chose to retry the connection to the card reader. Starts the search again, by dismissing modals and initializing from scratch /// @@ -67,11 +79,17 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { /// dereferenced and the state set to `idle` /// case discoveryFailed(Error) + + /// Waiting for other blocking events such as merchant education to complete to finish the connection process + /// + case waitingToComplete(CardReaderConnectionResult) } private let storageManager: StorageManagerType private let stores: StoresManager + private let locationService: LocationServiceProtocol + private var state: ControllerState { didSet { didSetState() @@ -80,10 +98,13 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { private let siteID: Int64 private let alertsPresenter: AlertPresenter + private let merchantEducationPresenter: BuiltInCardReaderMerchantEducationPresenting? private let configuration: CardPresentPaymentsConfiguration private let alertsProvider: AlertProvider + private let featureFlagService: FeatureFlagService + /// The reader we want the user to consider connecting to /// private var candidateReader: CardReader? @@ -118,8 +139,11 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { stores: StoresManager = ServiceLocator.stores, alertsPresenter: AlertPresenter, alertsProvider: AlertProvider, + merchantEducationPresenter: BuiltInCardReaderMerchantEducationPresenting? = nil, configuration: CardPresentPaymentsConfiguration, analyticsTracker: CardReaderConnectionAnalyticsTracker, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + locationService: LocationServiceProtocol = LocationService(), allowTermsOfServiceAcceptance: Bool = true ) { siteID = forSiteID @@ -128,8 +152,11 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { state = .idle self.alertsPresenter = alertsPresenter self.alertsProvider = alertsProvider + self.merchantEducationPresenter = merchantEducationPresenter self.configuration = configuration self.analyticsTracker = analyticsTracker + self.featureFlagService = featureFlagService + self.locationService = locationService self.allowTermsOfServiceAcceptance = allowTermsOfServiceAcceptance configureResultsControllers() @@ -176,14 +203,18 @@ private extension BuiltInCardReaderConnectionController { onRetry() case .cancel(let cancellationSource): onCancel(from: cancellationSource) + case .requestLocationPermission: + onRequestLocationPermission() case .connectToReader: onConnectToReader() - case .connectingFailed(let error): + case .connectingFailed(let error, _): onConnectingFailed(error: error) case .discoveryFailed(let error): onDiscoveryFailed(error: error) - case .updating(progress: let progress): + case .updating(progress: let progress, _): onUpdateProgress(progress: progress) + case .waitingToComplete, .connecting: + break } } @@ -253,7 +284,7 @@ private extension BuiltInCardReaderConnectionController { /// if cardReaders.isNotEmpty { self.candidateReader = cardReaders.first - self.state = .connectToReader + self.state = .requestLocationPermission return } }, @@ -275,7 +306,7 @@ private extension BuiltInCardReaderConnectionController { /// like to connect to it /// if candidateReader != nil { - self.state = .connectToReader + self.state = .requestLocationPermission return } @@ -330,6 +361,47 @@ private extension BuiltInCardReaderConnectionController { stores.dispatch(action) } + /// Handle location permission status and request + /// + func onRequestLocationPermission() { + let status = locationService.authorizationStatus + switch status { + case .authorized: + state = .connectToReader + case .denied: + analyticsTracker.cardReaderLocationPermissionRequiredShown() + observePermissionChanges() + alertsPresenter.present(viewModel: alertsProvider.locationRequired( + dismiss: { [weak self] in + guard let self else { return } + locationService.stopObservingPermissionChanges() + state = .cancel(.locationPermissionDenied) + }, + skip: { [weak self] in + guard let self else { return } + locationService.stopObservingPermissionChanges() + state = .connectToReader + } + )) + case .notDetermined: + analyticsTracker.cardReaderLocationPermissionPreAlertShown() + observePermissionChanges() + alertsPresenter.present(viewModel: alertsProvider.locationRequestPreAlert { [weak self] in + self?.locationService.requestPermission() + }) + } + } + + func observePermissionChanges() { + locationService.observePermissionChanges { [weak self] permission in + guard let self else { return } + locationService.stopObservingPermissionChanges() + if case .requestLocationPermission = state { + onRequestLocationPermission() + } + } + } + /// Connect to the candidate card reader /// func onConnectToReader() { @@ -350,15 +422,15 @@ private extension BuiltInCardReaderConnectionController { switch event { case .started(cancelable: let cancelable): self.softwareUpdateCancelable = cancelable - self.state = .updating(progress: 0) + self.state = .updating(progress: 0, educationInProgress: isEducationInProgress) case .installing(progress: let progress): if progress >= 0.995 { self.softwareUpdateCancelable = nil } - self.state = .updating(progress: progress) + self.state = .updating(progress: progress, educationInProgress: isEducationInProgress) case .completed: self.softwareUpdateCancelable = nil - self.state = .updating(progress: 1) + self.state = .updating(progress: 1, educationInProgress: isEducationInProgress) default: break } @@ -366,6 +438,34 @@ private extension BuiltInCardReaderConnectionController { .store(in: &self.subscriptions) } stores.dispatch(softwareUpdateAction) + + + if featureFlagService.isFeatureFlagEnabled(.tapToPayEducation), let presenter = merchantEducationPresenter { + let onboardingAction = CardPresentPaymentAction.observeBuiltInCardReaderAcceptToS { [weak self] events in + guard let self else { return } + + events + .subscribe(on: DispatchQueue.main) + .sink { [weak self] in + guard let self, !isEducationInProgress else { return } + + analyticsTracker.tapToPayTermsOfServiceAccepted() + + state = updatedState(educationInProgress: true) + presenter.presentMerchantEducation { [weak self] in + guard let self else { return } + state = updatedState(educationInProgress: false) + if case .waitingToComplete(let result) = state { + returnSuccess(result: result) + } + } + } + .store(in: &subscriptions) + } + stores.dispatch(onboardingAction) + } + + let options = CardReaderConnectionOptions( builtInOptions: BuiltInCardReaderConnectionOptions(termsOfServiceAcceptancePermitted: allowTermsOfServiceAcceptance)) @@ -378,14 +478,25 @@ private extension BuiltInCardReaderConnectionController { case .success(let reader): self.analyticsTracker.connectionSuccess(batteryLevel: reader.batteryLevel, cardReaderModel: reader.readerType.model) + + let success = { [weak self] in + guard let self else { return } + + if isEducationInProgress { + self.state = .waitingToComplete(.connected(reader)) + } else { + self.returnSuccess(result: .connected(reader)) + } + } + // If we were installing a software update, introduce a small delay so the user can // actually see a success message showing the installation was complete - if case .updating(progress: 1) = self.state { + if case .updating(progress: 1, _) = self.state { DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { - self.returnSuccess(result: .connected(reader)) + success() } } else { - self.returnSuccess(result: .connected(reader)) + success() } case .failure(let error): // The TOS acceptance flow happens during connection, not discovery, and cancelations from Apple's @@ -396,12 +507,13 @@ private extension BuiltInCardReaderConnectionController { self.analyticsTracker.connectionFailed(error: error, cardReaderModel: candidateReader.readerType.model) - self.state = .connectingFailed(error) + self.state = .connectingFailed(error: error, educationInProgress: isEducationInProgress) } } } stores.dispatch(action) + state = .connecting(educationInProgress: isEducationInProgress) alertsPresenter.present(viewModel: alertsProvider.connectingToReader()) } @@ -564,3 +676,31 @@ private extension CardReaderServiceUnderlyingError { } } } + +// MARK: - Merchant Education + +private extension BuiltInCardReaderConnectionController { + private var isEducationInProgress: Bool { + switch state { + case .connecting(let educationInProgress), + .connectingFailed(_, let educationInProgress), + .updating(_, let educationInProgress): + return educationInProgress + default: + return false + } + } + + private func updatedState(educationInProgress: Bool) -> ControllerState { + switch state { + case .connecting: + return .connecting(educationInProgress: educationInProgress) + case .updating(progress: let progress, educationInProgress: _): + return .updating(progress: progress, educationInProgress: educationInProgress) + case .connectingFailed(let error, _): + return .connectingFailed(error: error, educationInProgress: educationInProgress) + default: + return state + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderMerchantEducationPresenter.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderMerchantEducationPresenter.swift new file mode 100644 index 00000000000..246b0fba8e5 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/BuiltInCardReaderMerchantEducationPresenter.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftUI +import UIKit + +protocol BuiltInCardReaderMerchantEducationPresenting { + func presentMerchantEducation(completion: @escaping () -> Void) +} + +final class BuiltInCardReaderMerchantEducationPresenter: BuiltInCardReaderMerchantEducationPresenting { + private weak var rootViewController: ViewControllerPresenting? + + init(rootViewController: UIViewController) { + self.rootViewController = rootViewController + } + + func presentMerchantEducation(completion: @escaping () -> Void) { + let viewController = UIHostingController(rootView: TapToPayEducationView(viewModel: .init(completion: { _ in + completion() + }))) + let topViewController = rootViewController?.presentedViewController + topViewController?.present(viewController, animated: true) + } +} diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift index ac9000c0e65..72daab057d2 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardPresentPaymentsModalViewController.swift @@ -188,12 +188,14 @@ private extension CardPresentPaymentsModalViewController { primaryButton.applyPrimaryButtonStyle() primaryButton.titleLabel?.adjustsFontSizeToFitWidth = true primaryButton.titleLabel?.minimumScaleFactor = 0.5 + primaryButton.titleLabel?.lineBreakMode = .byClipping } func styleSecondaryButton() { secondaryButton.applyPaymentsModalCancelButtonStyle() secondaryButton.titleLabel?.adjustsFontSizeToFitWidth = true secondaryButton.titleLabel?.minimumScaleFactor = 0.5 + secondaryButton.titleLabel?.lineBreakMode = .byClipping } func styleAuxiliaryButton() { @@ -202,6 +204,7 @@ private extension CardPresentPaymentsModalViewController { } auxiliaryButton.titleLabel?.minimumScaleFactor = 0.5 auxiliaryButton.titleLabel?.adjustsFontSizeToFitWidth = true + auxiliaryButton.titleLabel?.lineBreakMode = .byClipping } func initializeContent() { diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionAnalyticsTracker.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionAnalyticsTracker.swift index 6a34816839a..30e65f68eab 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionAnalyticsTracker.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionAnalyticsTracker.swift @@ -147,6 +147,27 @@ final class CardReaderConnectionAnalyticsTracker { ) } + func tapToPayTermsOfServiceAccepted() { + analytics.track(event: WooAnalyticsEvent.InPersonPayments.tapToPayTermsOfServiceAccepted( + gatewayID: gatewayID, + countryCode: configuration.countryCode) + ) + } + + func cardReaderLocationPermissionPreAlertShown() { + analytics.track(event: WooAnalyticsEvent.InPersonPayments.cardReaderLocationPermissionPreAlertShown( + gatewayID: gatewayID, + countryCode: configuration.countryCode) + ) + } + + func cardReaderLocationPermissionRequiredShown() { + analytics.track(event: WooAnalyticsEvent.InPersonPayments.cardReaderLocationPermissionRequiredShown( + gatewayID: gatewayID, + countryCode: configuration.countryCode) + ) + } + enum ConnectionType: String { case automaticReconnection = "automatic_reconnection" case userInitiated = "user_initiated" diff --git a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift index 3617e498ae1..2083db9bf95 100644 --- a/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift +++ b/WooCommerce/Classes/ViewRelated/CardPresentPayments/CardReaderConnectionController.swift @@ -39,6 +39,10 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { /// case foundSeveralReaders + /// Requests location permission during the connection process + /// + case requestLocationPermission + /// Attempting to connect to a card reader. The completion passed to `searchAndConnect` /// will be called with a `success` `Bool` `True` result if successful, after which the view controller /// passed to `searchAndConnect` will be dereferenced and the state set to `idle` @@ -84,6 +88,7 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { private let knownCardReaderProvider: CardReaderSettingsKnownReaderProvider private let alertsPresenter: AlertPresenter private let configuration: CardPresentPaymentsConfiguration + private let locationService: LocationServiceProtocol private let alertsProvider: AlertProvider @@ -140,7 +145,8 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { alertsPresenter: AlertPresenter, alertsProvider: AlertProvider, configuration: CardPresentPaymentsConfiguration, - analyticsTracker: CardReaderConnectionAnalyticsTracker + analyticsTracker: CardReaderConnectionAnalyticsTracker, + locationService: LocationServiceProtocol = LocationService() ) { siteID = forSiteID self.storageManager = storageManager @@ -154,6 +160,7 @@ where AlertProvider.AlertDetails == AlertPresenter.AlertDetails { skippedReaderIDs = [] self.configuration = configuration self.analyticsTracker = analyticsTracker + self.locationService = locationService configureResultsControllers() } @@ -200,6 +207,8 @@ private extension CardReaderConnectionController { onRetry() case .cancel(let cancellationSource): onCancel(from: cancellationSource) + case .requestLocationPermission: + onRequestLocationPermission() case .connectToReader: onConnectToReader() case .connectingFailed(let error): @@ -356,7 +365,7 @@ private extension CardReaderConnectionController { if !didAutoAdvance { didAutoAdvance = true self.candidateReader = foundKnownReader - self.state = .connectToReader + self.state = .requestLocationPermission return } } @@ -396,7 +405,7 @@ private extension CardReaderConnectionController { /// (unknown) reader, auto-connect to that known reader if let foundKnownReader = self.getFoundKnownReader() { self.candidateReader = foundKnownReader - self.state = .connectToReader + self.state = .requestLocationPermission return } @@ -437,7 +446,7 @@ private extension CardReaderConnectionController { viewModel: alertsProvider.foundReader( name: candidateReader.id, connect: { - self.state = .connectToReader + self.state = .requestLocationPermission }, continueSearch: { self.skippedReaderIDs.append(candidateReader.id) @@ -461,7 +470,7 @@ private extension CardReaderConnectionController { return } self.candidateReader = self.getFoundReaderByID(readerID: readerID) - self.state = .connectToReader + self.state = .requestLocationPermission }, cancelSearch: { [weak self] in self?.state = .cancel(.foundSeveralReaders) @@ -512,6 +521,47 @@ private extension CardReaderConnectionController { stores.dispatch(action) } + /// Handle location permission status and request + /// + func onRequestLocationPermission() { + let status = locationService.authorizationStatus + switch status { + case .authorized: + state = .connectToReader + case .denied: + analyticsTracker.cardReaderLocationPermissionRequiredShown() + observePermissionChanges() + alertsPresenter.present(viewModel: alertsProvider.locationRequired( + dismiss: { [weak self] in + guard let self else { return } + locationService.stopObservingPermissionChanges() + state = .cancel(.locationPermissionDenied) + }, + skip: { [weak self] in + guard let self else { return } + locationService.stopObservingPermissionChanges() + state = .connectToReader + } + )) + case .notDetermined: + analyticsTracker.cardReaderLocationPermissionPreAlertShown() + observePermissionChanges() + alertsPresenter.present(viewModel: alertsProvider.locationRequestPreAlert { [weak self] in + self?.locationService.requestPermission() + }) + } + } + + func observePermissionChanges() { + locationService.observePermissionChanges { [weak self] permission in + guard let self else { return } + locationService.stopObservingPermissionChanges() + if case .requestLocationPermission = state { + onRequestLocationPermission() + } + } + } + /// Connect to the candidate card reader /// func onConnectToReader() { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardView.swift index a2ad332fea4..32becbcd2b5 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardView.swift @@ -187,6 +187,9 @@ struct DashboardView: View { .sheet(isPresented: $viewModel.showingInAppFeedbackSurvey) { Survey(source: .inAppFeedback) } + .sheet(isPresented: $viewModel.showingTapToPayAwarenessMoment) { + TapToPayAwarenessMomentView() + } .onAppear { Task { await viewModel.onViewAppear() diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift index 6fe9e4e5556..f2030c88c74 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift @@ -58,6 +58,8 @@ final class DashboardViewModel: ObservableObject { @Published var showingInAppFeedbackSurvey = false + @Published var showingTapToPayAwarenessMoment = false + @Published private(set) var jetpackBannerVisibleFromAppSettings = false @Published private(set) var isSiteEligibleToInstallJetpack = true @@ -82,6 +84,7 @@ final class DashboardViewModel: ObservableObject { private let inboxEligibilityChecker: InboxEligibilityChecker private let usageTracksEventEmitter: StoreStatsUsageTracksEventEmitter private let blazeLocalNotificationScheduler: BlazeLocalNotificationScheduler + private let tapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining private var subscriptions: Set = [] @@ -114,7 +117,8 @@ final class DashboardViewModel: ObservableObject { blazeEligibilityChecker: BlazeEligibilityCheckerProtocol = BlazeEligibilityChecker(), inboxEligibilityChecker: InboxEligibilityChecker = InboxEligibilityUseCase(), googleAdsEligibilityChecker: GoogleAdsEligibilityChecker = DefaultGoogleAdsEligibilityChecker(), - localNotificationScheduler: BlazeLocalNotificationScheduler? = nil) { + localNotificationScheduler: BlazeLocalNotificationScheduler? = nil, + tapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining = TapToPayAwarenessMomentDeterminer()) { self.siteID = siteID self.stores = stores self.storageManager = storageManager @@ -151,6 +155,9 @@ final class DashboardViewModel: ObservableObject { blazeEligibilityChecker: blazeEligibilityChecker) self.blazeLocalNotificationScheduler.observeNotificationUserResponse() + self.tapToPayAwarenessMomentDeterminer = tapToPayAwarenessMomentDeterminer + configureTapToPayAwarnessMomentPresentation() + self.inAppFeedbackCardViewModel.onFeedbackGiven = { [weak self] feedback in self?.showingInAppFeedbackSurvey = feedback == .didntLike self?.onInAppFeedbackCardAction() @@ -525,6 +532,13 @@ private extension DashboardViewModel { func configureOrdersResultController() { func refreshHasOrders() { + /// Upon logging out, `CoreDataManager` clears the storage triggering data change. + /// Checking the authentication state helps avoiding reloading data + /// in the unauthenticated state. + guard stores.isAuthenticated else { + return + } + guard ordersResultsController.fetchedObjects.isEmpty else { hasOrders = true return @@ -538,13 +552,7 @@ private extension DashboardViewModel { ordersResultsController.onDidChangeContent = { refreshHasOrders() } - ordersResultsController.onDidResetContent = { [weak self] in - /// Upon logging out, `CoreDataManager` resets the storage and triggers the reset notification - /// causing refetching data. Checking the authentication state helps avoiding reloading data - /// in the unauthenticated state. - guard let self, stores.isAuthenticated else { - return - } + ordersResultsController.onDidResetContent = { refreshHasOrders() } @@ -783,6 +791,21 @@ private extension DashboardViewModel { } } +// MARK: - Tap to Pay awareness moment presentation + +private extension DashboardViewModel { + func configureTapToPayAwarnessMomentPresentation() { + Task { @MainActor [weak self] in + guard let self else { return } + + if await tapToPayAwarenessMomentDeterminer.shouldPresent() { + showingTapToPayAwarenessMoment = true + tapToPayAwarenessMomentDeterminer.setPresented() + } + } + } +} + // MARK: - Constants // private extension DashboardViewModel { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift index a982337444a..d0a0538c438 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift @@ -98,8 +98,9 @@ class StoreOnboardingViewModel: ObservableObject { $noTasksAvailableForDisplay .combineLatest(defaults.publisher(for: \.completedAllStoreOnboardingTasks)) .filter { [weak self] _ in - /// Upon logging out, `UserDefaults` is reset causing `completedAllStoreOnboardingTasks` to change value. - /// Checking the authentication state helps avoiding reloading data in the unauthenticated state. + /// Upon logging out, `CoreDataManager` clears the storage triggering data change. + /// Checking the authentication state helps avoiding reloading data + /// in the unauthenticated state. guard let self, self.stores.isAuthenticated else { return false } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift index df8d1077532..3ea7649270f 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesConfigurationViewModel.swift @@ -11,7 +11,6 @@ final class BetaFeaturesConfigurationViewModel: ObservableObject { init(appSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, posEligibilityChecker: POSEligibilityCheckerProtocol = POSEligibilityChecker( - cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(), siteSettings: ServiceLocator.selectedSiteSettings, currencySettings: ServiceLocator.currencySettings, featureFlagService: ServiceLocator.featureFlagService diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothReaderConnectionAlertsProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothReaderConnectionAlertsProvider.swift index bf3b57e1908..efd9acb72cc 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothReaderConnectionAlertsProvider.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BluetoothReaderConnectionAlertsProvider.swift @@ -85,4 +85,12 @@ struct BluetoothReaderConnectionAlertsProvider: BluetoothReaderConnnectionAlerts CardPresentModalUpdateFailedLowBattery(batteryLevel: batteryLevel, close: close) } + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { + CardPresentModalLocationPreAlert(requestPermission: requestPermission) + } + + func locationRequired(dismiss: @escaping () -> Void, + skip: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { + CardPresentModalLocationRequired(dismiss: dismiss) + } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift index 66da03ca41d..5143a6fe1e5 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInCardReaderPaymentAlertsProvider.swift @@ -60,20 +60,20 @@ final class BuiltInCardReaderPaymentAlertsProvider: CardReaderTransactionAlertsP receiptState: CardReaderTransactionFailureAlertReceiptState, tryAgain: @escaping () -> Void, dismissCompletion: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { - - switch receiptState { case let .paymentSuccessEmailSent(email): return CardPresentModalErrorEmailSent(errorDescription: builtInReaderDescription(for: error), transactionType: .collectPayment, image: .builtInReaderError, email: email, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), tryAgainAction: tryAgain, dismissCompletion: dismissCompletion) case let .promptToSendEmailReceipt(emailReceiptAction): return CardPresentModalError(errorDescription: builtInReaderDescription(for: error), transactionType: .collectPayment, image: .builtInReaderError, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), tryAgainAction: tryAgain, emailReceiptAction: emailReceiptAction, dismissCompletion: dismissCompletion) @@ -81,6 +81,7 @@ final class BuiltInCardReaderPaymentAlertsProvider: CardReaderTransactionAlertsP return CardPresentModalErrorWithoutEmail(errorDescription: builtInReaderDescription(for: error), transactionType: .collectPayment, image: .builtInReaderError, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), tryAgainAction: tryAgain, dismissCompletion: dismissCompletion) } @@ -95,17 +96,20 @@ final class BuiltInCardReaderPaymentAlertsProvider: CardReaderTransactionAlertsP errorDescription: builtInReaderDescription(for: error), image: .builtInReaderError, email: email, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), onDismiss: dismissCompletion) case let .promptToSendEmailReceipt(emailReceiptAction): CardPresentModalNonRetryableError(amount: amount, errorDescription: builtInReaderDescription(for: error), image: .builtInReaderError, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), onDismiss: dismissCompletion, emailReceiptAction: emailReceiptAction) case .noEmailReceipt: CardPresentModalNonRetryableErrorWithoutEmail(amount: amount, errorDescription: builtInReaderDescription(for: error), image: .builtInReaderError, + requiresFallbackPaymentMethod: errorRequiresFallbackPaymentMethod(error), onDismiss: dismissCompletion) } } @@ -136,6 +140,14 @@ private extension BuiltInCardReaderPaymentAlertsProvider { } } + func errorRequiresFallbackPaymentMethod(_ error: Error) -> Bool { + if let error = error as? CardPaymentErrorProtocol { + return error.requiresFallbackPaymentMethod + } else { + return false + } + } + enum Localization { static func errorDescription(underlyingError: UnderlyingError) -> String? { switch underlyingError { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInReaderConnectionAlertsProvider.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInReaderConnectionAlertsProvider.swift index 00fe9868526..8510984e8a2 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInReaderConnectionAlertsProvider.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/BuiltInReaderConnectionAlertsProvider.swift @@ -68,4 +68,13 @@ struct BuiltInReaderConnectionAlertsProvider: CardReaderConnectionAlertsProvidin cancel: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { CardPresentModalSelectSearchType(tapOnIPhoneAction: tapToPay, bluetoothAction: bluetooth, cancelAction: cancel) } + + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { + CardPresentModalLocationPreAlert(requestPermission: requestPermission) + } + + func locationRequired(dismiss: @escaping () -> Void, + skip: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { + CardPresentModalLocationRequired(dismiss: dismiss) + } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderConnectionAlertsProviding.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderConnectionAlertsProviding.swift index 4308626e45a..6667497dd6c 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderConnectionAlertsProviding.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/CardReaderConnectionAlertsProviding.swift @@ -59,6 +59,16 @@ protocol CardReaderConnectionAlertsProviding { func selectSearchType(tapToPay: @escaping () -> Void, bluetooth: @escaping () -> Void, cancel: @escaping () -> Void) -> AlertDetails + + /// Shows a modal explaining the proceeding native iOS location alert + /// + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> AlertDetails + + /// Shows a modal requiring location permissions to proceed + /// Skip callback is provided in case the alert presenter wants to skip the location requirement + /// + func locationRequired(dismiss: @escaping () -> Void, + skip: @escaping () -> Void) -> AlertDetails } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/PaymentSettingsFlowHint.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/PaymentSettingsFlowHint.swift index fb23bda6199..1640bfc0cef 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/PaymentSettingsFlowHint.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/PaymentSettingsFlowHint.swift @@ -9,7 +9,7 @@ struct PaymentSettingsFlowHint: View { Text(number, format: .number) .font(.callout) .padding(.all, 12) - .background(Color(UIColor.systemGray6)) + .background(Color(.init(light: .systemGray6, dark: .darkGray))) .clipShape(Circle()) Text(text) .font(.callout) @@ -17,6 +17,8 @@ struct PaymentSettingsFlowHint: View { Spacer() } .padding(.horizontal, 8) + .accessibilityElement() + .accessibilityLabel("\(number). \(text)") } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift index ab016cf4e8d..326d99b0603 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/SetUpTapToPayInformationViewController.swift @@ -14,6 +14,7 @@ final class SetUpTapToPayInformationViewController: UIHostingController.Publisher private let analytics: Analytics = ServiceLocator.analytics @@ -40,7 +41,8 @@ final class SetUpTapToPayInformationViewModel: PaymentSettingsFlowPresentedViewM onboardingStatePublisher: Published.Publisher, connectionAnalyticsTracker: CardReaderConnectionAnalyticsTracker, connectivityObserver: ConnectivityObserver = ServiceLocator.connectivityObserver, - stores: StoresManager = ServiceLocator.stores) { + stores: StoresManager = ServiceLocator.stores, + tapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining = TapToPayAwarenessMomentDeterminer()) { self.siteID = siteID self.configuration = configuration self.didChangeShouldShow = didChangeShouldShow @@ -49,6 +51,7 @@ final class SetUpTapToPayInformationViewModel: PaymentSettingsFlowPresentedViewM self.connectivityObserver = connectivityObserver self.onboardingStatePublisher = onboardingStatePublisher self.learnMoreURL = Self.learnMoreURL(for: .wcPay) // this will be updated when the onboarding state is known + self.tapToPayAwarenessMomentDeterminer = tapToPayAwarenessMomentDeterminer beginOnboardingStateObservation() beginConnectedReaderObservation() @@ -132,6 +135,7 @@ final class SetUpTapToPayInformationViewModel: PaymentSettingsFlowPresentedViewM func viewDidAppear() { NotificationCenter.default.post(name: .setUpTapToPayViewDidAppear, object: nil) + tapToPayAwarenessMomentDeterminer.setPresented() } /// Updates whether the view this viewModel is associated with should be shown or not diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentDeterminer.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentDeterminer.swift new file mode 100644 index 00000000000..ab1d99c6670 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentDeterminer.swift @@ -0,0 +1,81 @@ +import Foundation +import Yosemite +import Experiments + +protocol TapToPayAwarenessMomentDetermining { + func shouldPresent() async -> Bool + func setPresented() +} + +struct TapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining { + private let cardReaderSupportDeterminer: CardReaderSupportDetermining + private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol + private let featureFlagService: FeatureFlagService + + private let userDefaults: UserDefaults + + init(siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, + configuration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration, + cardReaderSupportDeterminer: CardReaderSupportDetermining? = nil, + cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, + userDefaults: UserDefaults = .standard) { + self.cardReaderSupportDeterminer = cardReaderSupportDeterminer ?? CardReaderSupportDeterminer(siteID: siteID, configuration: configuration) + self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding + self.featureFlagService = featureFlagService + self.userDefaults = userDefaults + } + + func shouldPresent() async -> Bool { + guard featureFlagService.isFeatureFlagEnabled(.tapToPayEducation) else { + return false + } + + guard !wasPresented() else { + return false + } + + // Do not present immediately after the merchant is eligible + // Avoid cases such as a fresh login + guard !isFirstAttempt() else { + setAttempted() + return false + } + + switch cardPresentPaymentsOnboarding.state { + case .completed, .codPaymentGatewayNotSetUp: + break + default: + return false + } + + async let deviceSupportsTapToPay = cardReaderSupportDeterminer.deviceSupportsLocalMobileReader() + async let siteSupportsTapToPay = cardReaderSupportDeterminer.siteSupportsLocalMobileReader() + async let hasPreviousTapToPayUsage = cardReaderSupportDeterminer.hasPreviousTapToPayUsage() + let deviceSupportsTapToPayResult = await deviceSupportsTapToPay + let siteSupportsTapToPayResult = await siteSupportsTapToPay + let hasPreviousTapToPayUsageResult = await hasPreviousTapToPayUsage + + return deviceSupportsTapToPayResult && siteSupportsTapToPayResult && !hasPreviousTapToPayUsageResult + } + + // MARK: - Previous Presentation + + func setPresented() { + userDefaults.set(true, forKey: UserDefaults.Key.tapToPayAwarenessMomentPresented.rawValue) + } + + private func wasPresented() -> Bool { + userDefaults.bool(forKey: UserDefaults.Key.tapToPayAwarenessMomentPresented.rawValue) + } + + // MARK: - Attempt + + private func setAttempted() { + userDefaults.set(true, forKey: UserDefaults.Key.tapToPayAwarenessMomentFirstLaunchCompleted.rawValue) + } + + private func isFirstAttempt() -> Bool { + !userDefaults.bool(forKey: UserDefaults.Key.tapToPayAwarenessMomentFirstLaunchCompleted.rawValue) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentView.swift new file mode 100644 index 00000000000..4ccc13b70da --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayAwarenessMomentView.swift @@ -0,0 +1,122 @@ +import Foundation +import SwiftUI +import Yosemite +import WooFoundation + +struct TapToPayAwarenessMomentView: View { + @Environment(\.dismiss) private var dismiss + private let imageName: String + + private let analytics: Analytics + private let deepLinkNavigator: DeepLinkNavigator? + @State private var destination: (any DeepLinkDestinationProtocol)? + + init(deepLinkNavigator: DeepLinkNavigator? = AppDelegate.shared.tabBarController, + countryCode: CountryCode = CardPresentConfigurationLoader().configuration.countryCode, + analytics: Analytics = ServiceLocator.analytics) { + self.analytics = analytics + self.deepLinkNavigator = deepLinkNavigator + switch countryCode { + case .GB: + imageName = "tap-to-pay-education-intro-gb" + default: + imageName = "tap-to-pay-education-intro-us" + } + } + + var body: some View { + NavigationStack { + VStack(spacing: 20) { + Text(Localization.title) + .font(.title.weight(.bold)) + .multilineTextAlignment(.center) + Text(Localization.subtitle) + .font(.title3) + .multilineTextAlignment(.center) + Image(imageName) + .resizable() + .scaledToFit() + + Spacer() + + Button(Localization.enable, action: { + analytics.track(.setUpTryOutTapToPayOnIPhoneTapped) + destination = PaymentsMenuDestination.tapToPay + dismiss() + }) + .buttonStyle(PrimaryButtonStyle()) + + Button(Localization.learnMore, action: { + analytics.track(.aboutTapToPayOnIPhoneTapped) + destination = PaymentsMenuDestination.aboutTapToPay + dismiss() + }) + .buttonStyle(TextButtonStyle()) + .padding(.bottom) + } + .padding([.horizontal]) + .wooNavigationBarStyle() + .toolbarBackground(.hidden, for: .navigationBar) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(Localization.close, action: { + dismiss() + }) + } + } + .onAppear { + analytics.track(.tapToPayAwarenessShown) + } + .onDisappear { + if let destination { + deepLinkNavigator?.navigate(to: destination) + } + } + } + } +} + +private enum Localization { + static let close = NSLocalizedString( + "tapToPay.awarenessMoment.closeButton", + value: "Close", + comment: "Title of the button to dismiss the Tap to Pay awareness screen" + ) + + static let title = NSLocalizedString( + "tapToPay.awarenessMoment.title", + value: "Tap to Pay on iPhone. Now available.", + comment: "Title for the Tap to Pay modal promoting the feature. When using the name “Tap to Pay " + + "on iPhone” in headlines or copy, do not shorten to “Tap to Pay” or “Apple Tap to Pay. Always " + + "typeset “Tap to Pay on iPhone” as five words. The T and Ps should be uppercased, followed by " + + "lowercase letters." + ) + + static let subtitle = NSLocalizedString( + "tapToPay.awarenessMoment.subtitle", + value: "Accept contactless payments with only an iPhone.", + comment: "A subtitle for the Tap to Pay modal promoting the feature." + ) + + static let enable = NSLocalizedString( + "tapToPay.awarenessMoment.enableButton", + value: "Enable now", + comment: "A title for CTA to start Tap to Pay set up process" + ) + + static let learnMore = NSLocalizedString( + "tapToPay.awarenessMoment.learnMore", + value: "Learn more", + comment: "A title for CTA to open a view explaining Tap to Pay" + ) +} + +#Preview("US Tap to Pay Awareness Moment") { + TapToPayAwarenessMomentView(deepLinkNavigator: nil, countryCode: .US) +} + +#Preview("GB Tap to Pay Awareness Moment") { + TapToPayAwarenessMomentView(deepLinkNavigator: nil, countryCode: .GB) + .preferredColorScheme(.dark) +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepView.swift index e88eaeacba6..c3a73699a46 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepView.swift @@ -15,6 +15,10 @@ struct TapToPayEducationStepView: View { .bold() .multilineTextAlignment(.center) Image(viewModel.imageName) + .resizable() + .scaledToFit() + .frame(maxHeight: 350) + .accessibilityHidden(true) if viewModel.descriptionSteps.count > 1 { ForEach(viewModel.descriptionSteps.indices, id: \.self) { index in PaymentSettingsFlowHint(number: index + 1, @@ -22,8 +26,15 @@ struct TapToPayEducationStepView: View { } } else if let description = viewModel.descriptionSteps.first { Text(description) - .font(.callout) + .font(.body) + .fixedSize(horizontal: false, vertical: true) } + + if let limit = viewModel.limit { + AboutTapToPayContactlessLimitView(viewModel: limit) + .padding([.top, .bottom]) + } + Spacer(minLength: 0) } .padding([.leading, .trailing], 24) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepViewModel.swift index f88e74e6a4f..0a0833f83de 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepViewModel.swift @@ -4,16 +4,25 @@ final class TapToPayEducationStepViewModel { let title: String let imageName: String let descriptionSteps: [String] + let limit: AboutTapToPayContactlessLimitViewModel? - init(title: String, imageName: String, descriptionSteps: [String]) { + init(title: String, + imageName: String, + descriptionSteps: [String], + limit: AboutTapToPayContactlessLimitViewModel? = nil) { self.title = title self.imageName = imageName self.descriptionSteps = descriptionSteps + self.limit = limit } - init(title: String, imageName: String, description: String) { + init(title: String, + imageName: String, + description: String, + limit: AboutTapToPayContactlessLimitViewModel? = nil) { self.title = title self.imageName = imageName self.descriptionSteps = [description] + self.limit = limit } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepsFactory.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepsFactory.swift new file mode 100644 index 00000000000..c335a67761e --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationStepsFactory.swift @@ -0,0 +1,178 @@ +import Foundation +import WooFoundation +import Yosemite + +struct TapToPayEducationStepsFactory { + static func steps(configuration: CardPresentPaymentsConfiguration) -> [TapToPayEducationStepViewModel] { + switch configuration.countryCode { + case .US: + return createUS() + case .GB: + return createGB(configuration: configuration) + default: + return createUS() + } + } + + // MARK: - US + + private static func createUS() -> [TapToPayEducationStepViewModel] { + [ + .init(title: Localization.Intro.title, + imageName: "tap-to-pay-education-intro-us", + description: Localization.Intro.description), + .init(title: Localization.ContactlessCard.title, + imageName: "tap-to-pay-education-contactless-cards-us", + descriptionSteps: Localization.ContactlessCard.descriptionSteps), + .init(title: Localization.ApplePay.title, + imageName: "tap-to-pay-education-apple-pay-us", + descriptionSteps: Localization.ApplePay.descriptionSteps) + ] + } + + // MARK: - GB + + private static func createGB(configuration: CardPresentPaymentsConfiguration) -> [TapToPayEducationStepViewModel] { + [ + .init(title: Localization.Intro.title, + imageName: "tap-to-pay-education-intro-gb", + description: Localization.Intro.description, + limit: .init(configuration: configuration)), + .init(title: Localization.ContactlessCard.title, + imageName: "tap-to-pay-education-contactless-cards-gb", + descriptionSteps: Localization.ContactlessCard.descriptionSteps), + .init(title: Localization.ApplePay.title, + imageName: "tap-to-pay-education-apple-pay-gb", + descriptionSteps: Localization.ApplePay.descriptionSteps), + .init(title: Localization.PIN.title, + imageName: "tap-to-pay-education-pin-gb", + description: Localization.PIN.description), + .init(title: Localization.FallbackPaymentMethod.title, + imageName: "tap-to-pay-education-fallback-payment-method-gb", + description: Localization.FallbackPaymentMethod.description), + ] + } +} + +// MARK: - Strings + +private enum Localization { + enum Intro { + static let title = NSLocalizedString( + "tapToPay.education.step.intro.title", + value: "Accept contactless payments with only an iPhone.", + comment: "Title for the initial Tap to Pay merchant education step" + ) + + static let description = NSLocalizedString( + "tapToPay.education.step.intro.description", + value: "With Tap to Pay on iPhone and the Woo app, you can accept in-person, contactless payments, " + + "right on your iPhone - from physical debit and credit cards, to Apple Pay and other digital " + + "wallets - no extra hardware needed. It’s easy, secure, and private.", + comment: "Description for the initial Tap to Pay merchant education step. When using the name “Tap to Pay " + + "on iPhone” in headlines or copy, do not shorten to “Tap to Pay” or “Apple Tap to Pay. Always " + + "typeset “Tap to Pay on iPhone” as five words. The T and Ps should be uppercased, followed by " + + "lowercase letters." + ) + } + + enum ContactlessCard { + static let title = NSLocalizedString( + "tapToPay.education.step.contactlessCard.title", + value: "How to accept contactless card with Tap to Pay on iPhone.", + comment: "Title for the 'How to accept contactless card' Tap to Pay merchant education" + ) + + static let descriptionSteps: [String] = [ + NSLocalizedString( + "tapToPay.education.step.contactlessCard.descriptionStep1", + value: "Create an order on your iPhone, add products or a custom amount, and check out with Tap to Pay on iPhone.", + comment: "First description step for the 'How to accept contactless card' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.contactlessCard.descriptionStep2", + value: "Present your iPhone to the customer.", + comment: "Second description step for the 'How to accept contactless card' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.contactlessCard.descriptionStep3", + value: "Your customer holds their card horizontally at the top of your iPhone, over the contactless symbol.", + comment: "Third description step for the 'How to accept contactless card' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.contactlessCard.descriptionStep4", + value: "When you see the Done checkmark, the card read is complete and the transaction is being processed.", + comment: "Fourth description step for the 'How to accept contactless card' Tap to Pay merchant education" + ) + ] + } + + enum ApplePay { + static let title = NSLocalizedString( + "tapToPay.education.step.applePay.title", + value: "How to accept Apple Pay and other digital wallets with Tap to Pay on iPhone.", + comment: "Title for the 'How to accept Apple Pay' Tap to Pay merchant education" + ) + + static let descriptionSteps: [String] = [ + NSLocalizedString( + "tapToPay.education.step.applePay.descriptionStep1", + value: "Create an order on your iPhone, add products or a custom amount, and check out with Tap to Pay on iPhone.", + comment: "First description step for the 'How to accept Apple Pay' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.applePay.descriptionStep2", + value: "Present your iPhone to the customer.", + comment: "Second description step for the 'How to accept Apple Pay' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.applePay.descriptionStep3", + value: "Your customer holds their device near your iPhone, over the contactless symbol.", + comment: "Third description step for the 'How to accept Apple Pay' Tap to Pay merchant education" + ), + NSLocalizedString( + "tapToPay.education.step.applePay.descriptionStep4", + value: "When you see the Done checkmark, the card read is complete and the transaction is being processed.", + comment: "Fourth description step for the 'How to accept Apple Pay' Tap to Pay merchant education" + ) + ] + } + + enum PIN { + static let title = NSLocalizedString( + "tapToPay.education.step.pin.title", + value: "How to handle PIN entry for a card.", + comment: "Title for the 'PIN entry' Tap to Pay merchant education step" + ) + + static let description = NSLocalizedString( + "tapToPay.education.step.pin.description", + value: "Customers are prompted to enter their card PIN under specific circumstances with Tap to Pay on iPhone.\n\n" + + "For customers needing visual or other assistance, accessibility options are accessed by selecting " + + "‘Accessibility Options’ on the PIN screen. Audible instructions guide customers to draw their PIN on the " + + "screen or tap the screen to indicate each digit - tapping once for 1, twice for 2, and so on. " + + "To submit their PIN, they simply swipe right with two fingers.", + comment: "Instructions for customers using accessibility options during PIN entry with Tap to Pay on iPhone." + + "Describes how to use audible guidance for drawing or tapping the PIN digits and the gesture to submit." + ) + } + + enum FallbackPaymentMethod { + static let title = NSLocalizedString( + "tapToPay.education.step.fallbackMethod.title", + value: "How to accept an alternative payment method.", + comment: "Title for the 'Fallback payment method' Tap to Pay merchant education step" + ) + + static let description = NSLocalizedString( + "tapToPay.education.step.fallbackMethod.description", + value: "Some cards are not able to complete contactless transactions using a PIN, which can result in payment failure.\n\n" + + "Ask the customer if they have another contactless card or digital wallet and select Try Collecting Again to " + + "continue the transaction using Tap to Pay on iPhone.\n\nOtherwise, select Try Another Payment Method and choose a " + + "supported alternative, such as Cash, Share Payment Link, Cash Reader, or Scan to Pay.", + comment: "Message displayed when a contactless transaction fails due to PIN issues. Provides steps to retry using " + + "another contactless payment method or alternative payment options." + ) + + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationView.swift index e45be95deca..9e744120106 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/CardReadersV2/Tap to Pay Education/TapToPayEducationView.swift @@ -3,13 +3,14 @@ import SwiftUI struct TapToPayEducationView: View { @StateObject private var viewModel: TapToPayEducationViewModel + @Environment(\.dismiss) private var dismiss init(viewModel: TapToPayEducationViewModel) { self._viewModel = StateObject(wrappedValue: viewModel) } var body: some View { - NavigationView { + NavigationStack { VStack(spacing: 8) { TabView(selection: $viewModel.selectedStep) { ForEach(0.. Void + private let siteID: Int64 + private let configuration: CardPresentPaymentsConfiguration + private let analytics: Analytics + private let completion: (TapToPayEducationResult) -> Void + private var result: TapToPayEducationResult = .done init(flow: Flow = .onboarding, steps: [TapToPayEducationStepViewModel]? = nil, siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, configuration: CardPresentPaymentsConfiguration = CardPresentConfigurationLoader().configuration, cardReaderSupportDeterminer: CardReaderSupportDetermining? = nil, - cardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingUseCaseProtocol? = nil, - onDismiss: @escaping () -> Void = {}) { + analytics: Analytics = ServiceLocator.analytics, + completion: @escaping (TapToPayEducationResult) -> Void) { self.flow = flow self.cardReaderSupportDeterminer = cardReaderSupportDeterminer ?? CardReaderSupportDeterminer(siteID: siteID, configuration: configuration) self.siteID = siteID self.configuration = configuration - if let cardPresentPaymentsOnboardingUseCase { - self.cardPresentPaymentsOnboardingUseCase = cardPresentPaymentsOnboardingUseCase - } else { - let onboardingUseCase = CardPresentPaymentsOnboardingUseCase() - self.cardPresentPaymentsOnboardingUseCase = onboardingUseCase - self.cardPresentPaymentsOnboardingUseCase.refresh() - } self.isInteractiveDismissDisabled = flow == .onboarding ? true : false - self.onDismiss = onDismiss - // TODO: Inject steps - self.steps = steps ?? [.init( - title: "How to accept contactless card with Tap to Pay on iPhone.", - imageName: "built-in-reader-preparing", - descriptionSteps: [ - "Create an order on your iPhone, add products or a custom amount, " - + "and check out with Tap to Pay on iPhone.", - "Present your iPhone to the customer.", - "When you see the Done checkmark, the card read is complete and the " - + "transaction is being processed.", - "When you see the Done checkmark, the card read." - ] - ), - .init( - title: "Accept contactless payments with only an iPhone.", - imageName: "built-in-reader-set-up", - descriptionSteps: [ - "With Tap to Pay on iPhone and the Woo app, you can accept in-person, " - + "contactless payments, right on your iPhone - from physical debit and " - + "credit cards, to Apple Pay and other digital wallets - no extra " - + "hardware needed. It’s easy, secure, and private." - ] - ), - .init( - title: "How to handle PIN entry for a card.", - imageName: "built-in-reader-processing", - descriptionSteps: [ - "Customer is prompted to enter their card PIN under specific " - + "circumstances with Tap to Pay on iPhone. For customers needing visual " - + "or other assistance, accessibility options are accessed by selecting " - + "‘Accessibility Options’ on the PIN screen. Audible instructions guide customers." - ] - )] + self.analytics = analytics + self.steps = steps ?? TapToPayEducationStepsFactory.steps(configuration: configuration) + self.completion = completion reloadHasPreviousTapToPayUsage() } @@ -104,12 +71,15 @@ final class TapToPayEducationViewModel: ObservableObject { if flow == .about && !hasPreviousTapToPayUsage { return Action(title: Localization.setUpTapToPay) { [weak self] in guard let self else { return } - showingSetUpFlow = true + analytics.track(.setUpTryOutTapToPayOnIPhoneTapped) + result = .setUpTapToPay + dismiss = true } } else { return Action(title: Localization.done) { [weak self] in guard let self else { return } - onDismiss() + analytics.track(.tapToPayEducationDone) + dismiss = true } } } else { @@ -125,7 +95,8 @@ final class TapToPayEducationViewModel: ObservableObject { if flow == .about, !hasPreviousTapToPayUsage { return Action(title: Localization.done) { [weak self] in guard let self else { return } - onDismiss() + analytics.track(.tapToPayEducationDone) + dismiss = true } } else { return nil @@ -133,6 +104,7 @@ final class TapToPayEducationViewModel: ObservableObject { } else { return Action(title: Localization.skip) { [weak self] in guard let self else { return } + analytics.track(.tapToPayEducationSkipped) skip() } } @@ -155,6 +127,16 @@ final class TapToPayEducationViewModel: ObservableObject { private func skip() { selectedStep = steps.count - 1 } + + // MARK: - View Events + + func onAppear() { + analytics.track(.tapToPayEducationShown) + } + + func onDisappear() { + completion(result) + } } extension TapToPayEducationViewModel { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Help/SystemStatusReport/SystemStatusReportViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Help/SystemStatusReport/SystemStatusReportViewModel.swift index 439c80e3619..ca8a5aea6b0 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Help/SystemStatusReport/SystemStatusReportViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Help/SystemStatusReport/SystemStatusReportViewModel.swift @@ -44,7 +44,7 @@ private extension SystemStatusReportViewModel { /// Format system status to match with Core's report. /// Not localizing content and keep English by default. /// - func formatReport(with systemStatus: SystemStatus) -> String { + func formatReport(with systemStatus: SystemStatusReport) -> String { var lines = ["### System Status Report generated via the WooCommerce iOS app ###"] // Environment diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayView.swift index 3415e17ae37..c243035cb87 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayView.swift @@ -168,9 +168,7 @@ struct AboutTapToPayContactlessLimitView: View { } .padding() .frame(maxWidth: .infinity, alignment: .topLeading) - .background(Color.withColorStudio( - name: .wooCommercePurple, - shade: .shade0)) + .background(Color(.wooCommercePurple(.shade0))) .cornerRadius(Layout.cornerRadius) .sheet(isPresented: $showingWebView) { WebViewSheet(viewModel: viewModel.webViewModel) { @@ -187,7 +185,8 @@ private extension AboutTapToPayContactlessLimitView { comment: "Heading for the details pane showing the contactless limit on About Tap to Pay") static let overLimitSuggestion = NSLocalizedString( - "To accept payments above this limit, consider purchasing a card reader.", + "tapToPay.aboutTapToPay.overLimitSuggestion", + value: "To accept all payments above this limit, consider purchasing a card reader.", comment: "A suggestion to buy a hardware card reader to handle transactions above the contactless limit, " + "shown on the About Tap to Pay screen") diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayViewModel.swift index c83da2a45de..75127495c85 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayViewModel.swift @@ -142,8 +142,12 @@ private extension CardPresentPaymentsConfiguration { "translated, however this string is only used when there's a problem decoding the limit, so it's acceptable.") static let contactlessLimitWithAmountGB = NSLocalizedString( - "In the United Kingdom, cards may only be used with Tap to Pay for transactions up to %1$@.", + "tapToPay.aboutTapToPay.contactlessLimit.gb", + value: "In the United Kingdom, you can accept card payments with Tap to Pay for transactions up to %1$@. " + + "For payments over %1$@, some cards allow customers to enter their PIN directly on the phone, " + + "while others require a card reader to complete the payment.", comment: "A description of the contactless limit, shown on the About Tap to Pay screen. This string is for " + - "the UK specifically. %1$@ will be replaced with the limit amount in £ formatted correctly for the locale.") + "the UK specifically. %1$@ will be replaced with the limit amount in £ formatted correctly for the locale." + ) } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift index e72fda392e5..0979ee26cc1 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenu.swift @@ -75,8 +75,14 @@ struct InPersonPaymentsMenu: View { await viewModel.onAppear() } }) { - TapToPayEducationView(viewModel: .init(flow: .about, onDismiss: { - viewModel.presentAboutTapToPay = false + TapToPayEducationView(viewModel: .init(flow: .about, + completion: { result in + switch result { + case .setUpTapToPay: + viewModel.presentSetUpTryOutTapToPay = true + default: + break + } })) } } else { @@ -365,7 +371,7 @@ private extension InPersonPaymentsMenu { value: "About Tap to Pay", comment: "Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits " + "of Tap to Pay on iPhone, relevant to the store territory." - ).localizedCapitalized + ) static let done = NSLocalizedString( "menu.payments.wooPaymentsPayouts.navigation.done.button.title", diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenuViewModel.swift index d5912264832..5cc2a58a4df 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/Payments Menu/InPersonPaymentsMenuViewModel.swift @@ -425,6 +425,8 @@ extension InPersonPaymentsMenuViewModel: DeepLinkNavigator { collectPayment() case .tapToPay: presentSetUpTryOutTapToPay = true + case .aboutTapToPay: + presentAboutTapToPay = true } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift index 0d6aea6ecb3..bb53479759e 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSEligibilityChecker.swift @@ -24,11 +24,6 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { .eraseToAnyPublisher() } - guard featureFlagService.isFeatureFlagEnabled(.paymentsOnboardingInPointOfSale) else { - return Publishers.CombineLatest3(isOnboardingComplete, isWooCommerceVersionSupported, isPointOfSaleFeatureFlagEnabled) - .map { $0 && $1 && $2 } - .eraseToAnyPublisher() - } return Publishers.CombineLatest(isWooCommerceVersionSupported, isPointOfSaleFeatureFlagEnabled) .filter { [weak self] _ in self?.isEligibleFromSiteChecks ?? false @@ -38,14 +33,12 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { } private let userInterfaceIdiom: UIUserInterfaceIdiom - private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol private let siteSettings: SelectedSiteSettings private let currencySettings: CurrencySettings private let stores: StoresManager private let featureFlagService: FeatureFlagService init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom, - cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings, currencySettings: CurrencySettings = ServiceLocator.currencySettings, stores: StoresManager = ServiceLocator.stores, @@ -53,25 +46,12 @@ final class POSEligibilityChecker: POSEligibilityCheckerProtocol { self.userInterfaceIdiom = userInterfaceIdiom self.siteSettings = siteSettings self.currencySettings = currencySettings - self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding self.stores = stores self.featureFlagService = featureFlagService } } private extension POSEligibilityChecker { - var isOnboardingComplete: AnyPublisher { - return cardPresentPaymentsOnboarding.statePublisher - .filter { [weak self] _ in - self?.isEligibleFromSiteChecks ?? false - } - .map { onboardingState in - // Woo Payments plugin enabled and user setup complete - onboardingState == .completed(plugin: .wcPayOnly) || onboardingState == .completed(plugin: .wcPayPreferred) - } - .eraseToAnyPublisher() - } - var isWooCommerceVersionSupported: AnyPublisher { Future { [weak self] promise in guard let self else { @@ -126,9 +106,15 @@ private extension POSEligibilityChecker { var isEligibleFromSiteChecks: Bool { // Conditions that can change if site settings are synced during the lifetime. - let isCountryCodeUS = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode == .US - let isCurrencyUSD = currencySettings.currencyCode == .USD - return isCountryCodeUS && isCurrencyUSD + let countryCode = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode + let currency = currencySettings.currencyCode + switch (countryCode, currency) { + case (.US, .USD), + (.GB, .GBP): + return true + default: + return false + } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StatsTimeRangePicker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StatsTimeRangePicker.swift index e62f8ea243c..2ca31d3e1d4 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StatsTimeRangePicker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StatsTimeRangePicker.swift @@ -16,6 +16,7 @@ struct StatsTimeRangePicker: View { } label: { SelectableItemRow(title: range.tabTitle, selected: isTimeRangeSelected(range)) } + .accessibilityIdentifier(range.menuAccessibilityIdentifier) } } label: { Image(systemName: "calendar") @@ -34,3 +35,20 @@ struct StatsTimeRangePicker: View { #Preview { StatsTimeRangePicker(currentTimeRange: .today, onSelect: { _ in }) } + +private extension StatsTimeRangeV4 { + var menuAccessibilityIdentifier: String { + switch self { + case .today: + "time-range-today" + case .thisWeek: + "time-range-this-week" + case .thisMonth: + "time-range-this-month" + case .thisYear: + "time-range-this-year" + case .custom: + "time-range-custom" + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceView.swift index 36d1934a418..1a58b93ce70 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceView.swift @@ -156,6 +156,7 @@ private extension StorePerformanceView { } } .disabled(viewModel.syncingData) + .accessibilityIdentifier("performance-time-range-menu") } } @@ -171,6 +172,7 @@ private extension StorePerformanceView { .fontWeight(.semibold) .foregroundStyle(statsValueColor) .largeTitleStyle() + .accessibilityIdentifier("revenue-value") Text(Localization.revenue) .if(!viewModel.hasRevenue) { $0.foregroundStyle(Color(.textSubtle)) } @@ -267,6 +269,7 @@ private extension StorePerformanceView { viewModel.didSelectStatsInterval(at: selectedIndex) } .frame(height: Layout.chartViewHeight) + .accessibilityIdentifier("store-stats-chart") if viewModel.hasRevenue, let granularityText = viewModel.granularityText { diff --git a/WooCommerce/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift b/WooCommerce/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift index 5c911751527..7f0bd2e532c 100644 --- a/WooCommerce/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift +++ b/WooCommerce/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift @@ -58,15 +58,18 @@ final class TooltipPresenter { } private var previousDeviceOrientation: UIDeviceOrientation? + private var animation: TooltipAnimation.Type init(containerView: UIView, tooltip: Tooltip, target: Target, + animation: TooltipAnimation.Type = UIView.self, primaryTooltipAction: (() -> Void)? = nil, secondaryTooltipAction: (() -> Void)? = nil ) { self.containerView = containerView self.tooltip = tooltip + self.animation = animation self.primaryTooltipAction = primaryTooltipAction self.secondaryTooltipAction = secondaryTooltipAction self.target = target @@ -99,7 +102,7 @@ final class TooltipPresenter { } func dismissTooltip() { - UIView.animate( + animation.animate( withDuration: Constants.tooltipAnimationDuration, delay: 0, options: .curveEaseOut @@ -125,20 +128,20 @@ final class TooltipPresenter { } private func animateTooltipIn() { - UIView.animate( + animation.animate( withDuration: Constants.tooltipAnimationDuration, delay: 0, - options: .curveEaseOut - ) { - guard let tooltipTopConstraint = self.tooltipTopConstraint else { - return - } + options: .curveEaseOut, + animations: { + guard let tooltipTopConstraint = self.tooltipTopConstraint else { + return + } - self.tooltip.alpha = 1 - tooltipTopConstraint.constant -= Constants.tooltipTopConstraintAnimationOffset + self.tooltip.alpha = 1 + tooltipTopConstraint.constant -= Constants.tooltipTopConstraintAnimationOffset - self.containerView.layoutIfNeeded() - } + self.containerView.layoutIfNeeded() + }, completion: nil) } private func configureDismissal() { @@ -202,7 +205,7 @@ final class TooltipPresenter { } @objc private func resetTooltipAndShow() { - UIView.animate( + animation.animate( withDuration: Constants.tooltipAnimationDuration, delay: 0, options: .curveEaseOut @@ -273,3 +276,16 @@ final class TooltipPresenter { } } } + +// MARK: - TooltipAnimation + +/// Enable dependency injection for animation +protocol TooltipAnimation: AnyObject { + static func animate(withDuration duration: TimeInterval, + delay: TimeInterval, + options: UIView.AnimationOptions, + animations: @escaping () -> Void, + completion: ((Bool) -> Void)?) +} + +extension UIView: TooltipAnimation {} diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 32159341e8c..aa8f9ba9f84 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -22,6 +22,9 @@ protocol FilterListViewModel { /// The final value returned to the caller of `FilterListViewController`. var criteria: Criteria { get } + /// Whether to display the entry point to the filter history + var shouldShowHistory: Bool { get } + // Navigation & Actions /// Resets the filter criteria. @@ -102,6 +105,7 @@ final class FilterListViewController: UIViewCont }() private var clearAllBarButtonItem: UIBarButtonItem? + private var historyBarButtonItem: UIBarButtonItem? private var selectedFilterTypeSubscription: AnyCancellable? private var selectedFilterValueSubscription: AnyCancellable? @@ -182,6 +186,10 @@ final class FilterListViewController: UIViewCont listSelector.reloadData() onClearAction() } + + @objc private func showFilterHistory() { + // TODO-14791: show history view + } } // MARK: - View Configuration @@ -196,6 +204,15 @@ private extension FilterListViewController { let clearAllButtonTitle = NSLocalizedString("Clear all", comment: "Button title for clearing all filters for the list.") clearAllBarButtonItem = UIBarButtonItem(title: clearAllButtonTitle, style: .plain, target: self, action: #selector(clearAllButtonTapped)) + + if viewModel.shouldShowHistory { + historyBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock"), style: .plain, target: self, action: #selector(showFilterHistory)) + historyBarButtonItem?.accessibilityHint = NSLocalizedString( + "filterListViewController.historyBarButtonItem.accessibilityHint", + value: "Filter history", + comment: "Accessibility hint for the filter history button on the filter list screen" + ) + } } func configureMainView() { @@ -347,7 +364,14 @@ private extension FilterListViewController { } func updateClearAllActionVisibility(numberOfActiveFilters: Int) { - listSelector.navigationItem.rightBarButtonItem = numberOfActiveFilters > 0 ? clearAllBarButtonItem: nil + let buttonItems: [UIBarButtonItem] = { + var contents = [historyBarButtonItem].compactMap { $0 } + if numberOfActiveFilters > 0, let clearAllBarButtonItem { + contents.append(clearAllBarButtonItem) + } + return contents + }() + listSelector.navigationItem.rightBarButtonItems = buttonItems } } diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift index 4e85331e0a2..0b6b74c469f 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift @@ -31,6 +31,25 @@ struct HubMenu: View { .onAppear { viewModel.setupMenuElements() } + .fullScreenCover(isPresented: $viewModel.showsPOS) { + if let cardPresentPaymentService = viewModel.cardPresentPaymentService, + let receiptService = POSReceiptService(siteID: viewModel.siteID, + credentials: viewModel.credentials), + let orderService = POSOrderService(siteID: viewModel.siteID, + credentials: viewModel.credentials) { + PointOfSaleEntryPointView( + itemsController: PointOfSaleItemsController(itemProvider: viewModel.posItemProvider), + onPointOfSaleModeActiveStateChange: { isEnabled in + viewModel.updateDefaultConfigurationForPointOfSale(isEnabled) + }, + cardPresentPaymentService: cardPresentPaymentService, + orderController: PointOfSaleOrderController(orderService: orderService, + receiptService: receiptService)) + } else { + // TODO: When we have a singleton for the card payment service, this should not be required. + Text("Error creating card payment service") + } + } } } @@ -46,6 +65,8 @@ struct HubMenu: View { ServiceLocator.analytics.track(.hubMenuSettingsTapped) case HubMenuViewModel.Blaze.id: ServiceLocator.analytics.track(event: .Blaze.blazeCampaignListEntryPointSelected(source: .menu)) + case HubMenuViewModel.PointOfSaleEntryPoint.id: + viewModel.showsPOS = true default: break } @@ -154,21 +175,6 @@ private extension HubMenu { SubscriptionsView(viewModel: .init()) case .customers: CustomersListView(viewModel: .init(siteID: viewModel.siteID)) - case .pointOfSales: - if let cardPresentPaymentService = viewModel.cardPresentPaymentService, - let orderService = POSOrderService(siteID: viewModel.siteID, - credentials: viewModel.credentials) { - PointOfSaleEntryPointView( - itemsController: PointOfSaleItemsController(itemProvider: viewModel.posItemProvider), - onPointOfSaleModeActiveStateChange: { isEnabled in - viewModel.updateDefaultConfigurationForPointOfSale(isEnabled) - }, - cardPresentPaymentService: cardPresentPaymentService, - orderController: PointOfSaleOrderController(orderService: orderService)) - } else { - // TODO: When we have a singleton for the card payment service, this should not be required. - Text("Error creating card payment service") - } case .reviewDetails(let parcel): reviewDetailView(parcel: parcel) case .blazeCampaignDetails(let campaignID): diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift index dfd607d6534..31872b6fa3d 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewController.swift @@ -108,20 +108,7 @@ private extension HubMenuViewController { guard let navigationController else { return } - googleAdsCampaignCoordinator = GoogleAdsCampaignCoordinator( - siteID: viewModel.siteID, - siteAdminURL: viewModel.woocommerceAdminURL.absoluteString, - source: .moreMenu, - shouldStartCampaignCreation: viewModel.hasGoogleAdsCampaigns, - shouldAuthenticateAdminPage: viewModel.shouldAuthenticateAdminPage, - navigationController: navigationController, - onCompletion: { [weak self] createdNewCampaign in - guard createdNewCampaign else { - return - } - self?.viewModel.refreshGoogleAdsCampaignCheck() - } - ) + googleAdsCampaignCoordinator = viewModel.createGoogleAdsCampaignCoordinator(with: navigationController) googleAdsCampaignCoordinator?.start() ServiceLocator.analytics.track(event: .GoogleAds.entryPointTapped( diff --git a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift index 15086082b64..66a5de44354 100644 --- a/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift @@ -28,7 +28,6 @@ enum HubMenuNavigationDestination: Hashable { case inAppPurchase case subscriptions case customers - case pointOfSales case reviewDetails(parcel: ProductReviewFromNoteParcel) } @@ -83,6 +82,9 @@ final class HubMenuViewModel: ObservableObject { @Published private(set) var hasGoogleAdsCampaigns = false @Published private var currentSite: Yosemite.Site? + /// Whether the app is in POS mode for an eligible site. + @Published var showsPOS: Bool = false + private let stores: StoresManager private let featureFlagService: FeatureFlagService private let generalAppSettings: GeneralAppSettingsStorage @@ -95,9 +97,10 @@ final class HubMenuViewModel: ObservableObject { private(set) lazy var posItemProvider: PointOfSaleItemServiceProtocol = { let currencySettings = ServiceLocator.currencySettings - return PointOfSaleProductService(siteID: siteID, - currencySettings: currencySettings, - credentials: credentials) + return PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + credentials: credentials, + isVariableProductsFeatureEnabled: featureFlagService.isFeatureFlagEnabled(.variableProductsInPointOfSale)) }() private(set) lazy var inboxViewModel = InboxViewModel(siteID: siteID) @@ -156,8 +159,7 @@ final class HubMenuViewModel: ObservableObject { self.blazeEligibilityChecker = blazeEligibilityChecker self.googleAdsEligibilityChecker = googleAdsEligibilityChecker self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase() - self.posEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: cardPresentPaymentsOnboarding, - siteSettings: ServiceLocator.selectedSiteSettings, + self.posEligibilityChecker = POSEligibilityChecker(siteSettings: ServiceLocator.selectedSiteSettings, currencySettings: ServiceLocator.currencySettings, featureFlagService: featureFlagService) self.analytics = analytics @@ -175,7 +177,7 @@ final class HubMenuViewModel: ObservableObject { refreshGoogleAdsCampaignCheck() } - if isSiteEligibleForBlaze { + if !isSiteEligibleForBlaze { refreshBlazeEligibilityCheck() } } @@ -221,7 +223,6 @@ final class HubMenuViewModel: ObservableObject { } func updateDefaultConfigurationForPointOfSale(_ isPointOfSaleActive: Bool) { - updateTabBarVisibility(isPointOfSaleActive) updateInAppNotifications(isPointOfSaleActive) } @@ -236,6 +237,23 @@ final class HubMenuViewModel: ObservableObject { analytics.track(.hubMenuOptionTapped, withProperties: eventProperties) } + func createGoogleAdsCampaignCoordinator(with navigationController: UINavigationController) -> GoogleAdsCampaignCoordinator { + GoogleAdsCampaignCoordinator( + siteID: siteID, + siteAdminURL: woocommerceAdminURL.absoluteString, + source: .moreMenu, + shouldStartCampaignCreation: !hasGoogleAdsCampaigns, + shouldAuthenticateAdminPage: shouldAuthenticateAdminPage, + navigationController: navigationController, + onCompletion: { [weak self] createdNewCampaign in + guard createdNewCampaign else { + return + } + self?.refreshGoogleAdsCampaignCheck() + } + ) + } + deinit { NotificationCenter.default.removeObserver(self, name: .setUpTapToPayViewDidAppear, object: nil) } @@ -244,38 +262,6 @@ final class HubMenuViewModel: ObservableObject { // MARK: - Helper method for WooCommerce POS // private extension HubMenuViewModel { - // Hides the app's tab bars when Point of Sale is active - // - func updateTabBarVisibility(_ isPointOfSaleActive: Bool) { - guard let mainTabBarController = AppDelegate.shared.tabBarController else { - return - } - /* - When hidding the app's tab bar on POS initialization, we've observed a recurring issue with the UI not being updated appropiately, - leaving additional padding in the bottom rather than re-positioning components taking all the available space. - In order to address this issue we have to explicitely call for an update to the safeAreaInsets in order to trigger the layout update we need, - so that the view controller's view can occupy the space left by the hidden tab bar. - Updating the bottom UIEdgeInset to a non-zero value seems to be enough to trigger the UI layout refresh we need. - Ref: gh-13785 - */ - if isPointOfSaleActive { - UIView.animate(withDuration: 0.5) { - mainTabBarController.tabBar.alpha = 0 - } completion: { _ in - mainTabBarController.tabBar.isHidden = isPointOfSaleActive - let bottomInset = CGFloat.leastNonzeroMagnitude - mainTabBarController.additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0) - } - } else { - mainTabBarController.tabBar.isHidden = isPointOfSaleActive - mainTabBarController.additionalSafeAreaInsets = .zero - mainTabBarController.tabBar.alpha = 0 - UIView.animate(withDuration: 0.5) { - mainTabBarController.tabBar.alpha = 1 - } - } - } - // Disables foreground in-app notifications when Point of Sale is active // func updateInAppNotifications(_ isPointOfSaleActive: Bool) { @@ -297,7 +283,6 @@ private extension HubMenuViewModel { } func setupPOSElement() { - cardPresentPaymentsOnboarding.refreshIfNecessary() posEligibilityChecker.isEligible.map { isEligibleForPOS in if isEligibleForPOS { return PointOfSaleEntryPoint() @@ -659,7 +644,8 @@ extension HubMenuViewModel { let accessibilityIdentifier: String = "menu-pointOfSale" let trackingOption: String = "pointOfSale" let iconBadge: HubMenuBadgeType? = nil - let navigationDestination: HubMenuNavigationDestination? = .pointOfSales + // POS is presented with its own navigation stack as nested navigation stack is not supported. + let navigationDestination: HubMenuNavigationDestination? = nil } struct Subscriptions: HubMenuItem { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CardPresentPaymentReceiptEmailCoordinator.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CardPresentPaymentReceiptEmailCoordinator.swift index 656778dbb9a..9d202492902 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CardPresentPaymentReceiptEmailCoordinator.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CardPresentPaymentReceiptEmailCoordinator.swift @@ -1,5 +1,6 @@ import MessageUI import UIKit +import SwiftUI import struct Yosemite.Order import WooFoundation @@ -58,6 +59,55 @@ final class CardPresentPaymentReceiptEmailCoordinator: NSObject { viewController.present(mail, animated: true) } + + /// Presents the email form after a payment is completed. + /// - Parameters: + /// - viewController: view controller to present the email form. + /// - order: order to be updated. + /// - onCompleted: called when the user completes emailing the receipt. + func presentSendReceiptAfterPayment(from viewController: ViewControllerPresenting, + order: Order, + onCompleted: @escaping ((Order?) -> Void)) { + analytics.track(event: .InPersonPayments.receiptEmailTapped( + countryCode: countryCode, + cardReaderModel: cardReaderModel, + source: .api) + ) + + let noticePresenter = DefaultNoticePresenter() + + let receiptEmailViewModel = ReceiptEmailViewModel(order: order) { [weak self] result in + guard let self else { return } + switch result { + case .success(let order): + analytics.track(event: .InPersonPayments.receiptEmailSuccess( + countryCode: countryCode, + cardReaderModel: cardReaderModel, + source: .api) + ) + onCompleted(order) + case .failure(let error): + analytics.track(event: .InPersonPayments.receiptEmailFailed( + error: error, + countryCode: countryCode, + cardReaderModel: cardReaderModel, + source: .api) + ) + noticePresenter.enqueue(notice: Notice(title: Localization.errorNotice, feedbackType: .error)) + case .canceled: + analytics.track(event: .InPersonPayments.receiptEmailCanceled( + countryCode: countryCode, + cardReaderModel: cardReaderModel, + source: .api) + ) + onCompleted(nil) + } + } + + let receiptEmailViewController = UIHostingController(rootView: ReceiptEmailView(viewModel: receiptEmailViewModel)) + noticePresenter.presentingViewController = receiptEmailViewController + viewController.present(receiptEmailViewController, animated: true) + } } // MARK: MailComposer Delegate @@ -117,5 +167,11 @@ private extension CardPresentPaymentReceiptEmailCoordinator { } return .localizedStringWithFormat(collectPaymentWithName, username) } + + static let errorNotice = NSLocalizedString( + "order.receiptEmailView.errorNotice", + value: "Error sending the email receipt. Please try again.", + comment: "An error that is shown when sending email receipt fails." + ) } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift index e92abde333b..3e1e5b69784 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Collect Payments/CollectOrderPaymentUseCase.swift @@ -473,7 +473,9 @@ private extension CollectOrderPaymentUseCase { } } - getReceiptStateForFailedPayment(error: error, completion: presentErrorAlert) + getReceiptStateForFailedPayment(error: error, + paymentGatewayID: paymentGatewayAccount.gatewayID, + completion: presentErrorAlert) } private func presentRetryByRestartingError(error: Error, @@ -716,24 +718,25 @@ private extension CollectOrderPaymentUseCase { } } - // MARK: - Collect customer email and send receipt after payment presentation private extension CollectOrderPaymentUseCase { func presentSendReceiptAfterPayment(onCompleted: @escaping ((Order?) -> Void)) { - let receiptEmailViewController = ReceiptEmailViewHostingController(order: order) { order in - onCompleted(order) - } + let coordinator = CardPresentPaymentReceiptEmailCoordinator(countryCode: configuration.countryCode, + cardReaderModel: analyticsTracker.connectedReaderModel) + receiptEmailCoordinator = coordinator - // Support opening receipt modal on top of a presented alert if needed let viewController = rootViewController.presentedViewController ?? rootViewController - viewController.present(receiptEmailViewController, animated: true) + coordinator.presentSendReceiptAfterPayment(from: viewController, order: order) { [weak self] order in + self?.receiptEmailCoordinator = nil + onCompleted(order) + } } private func getReceiptStateForSuccessPayment( presentBackendReceiptAction: @escaping () -> Void, noReceiptAction: @escaping () -> Void, completion: @escaping (CardReaderTransactionAlertReceiptState) -> Void) { - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment { isEligibleSendingReceiptAfterPayment in + receiptEligibilityUseCase.isEligibleForSuccessfulPaymentEmailReceipts { isEligibleSendingReceiptAfterPayment in let receiptState: CardReaderTransactionAlertReceiptState if let email = self.order.billingAddress?.email, email.isNotEmpty { @@ -754,10 +757,6 @@ private extension CollectOrderPaymentUseCase { } }, noReceiptAction: noReceiptAction) - } else if MFMailComposeViewController.canSendMail() { - receiptState = .promptToSendEmailReceipt(printReceiptAction: presentBackendReceiptAction, - emailReceiptAction: presentBackendReceiptAction, - noReceiptAction: noReceiptAction) } else { receiptState = .emailSendingNotSupported(printReceiptAction: presentBackendReceiptAction, noReceiptAction: noReceiptAction) @@ -768,6 +767,7 @@ private extension CollectOrderPaymentUseCase { } private func getReceiptStateForFailedPayment(error: Error, + paymentGatewayID: String, completion: @escaping (CardReaderTransactionFailureAlertReceiptState) -> Void) { let isErrorEligibleForSendingFailureReceiptAfterPayment: Bool = { switch error { @@ -786,7 +786,7 @@ private extension CollectOrderPaymentUseCase { return completion(.noEmailReceipt) } - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment { [weak self] isEligible in + receiptEligibilityUseCase.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: paymentGatewayID) { [weak self] isEligible in guard let self else { return } let receiptState: CardReaderTransactionFailureAlertReceiptState @@ -977,6 +977,7 @@ enum CardPaymentRetryApproach { protocol CardPaymentErrorProtocol: Error { var retryApproach: CardPaymentRetryApproach { get } + var requiresFallbackPaymentMethod: Bool { get } } extension CardReaderServiceError: CardPaymentErrorProtocol { @@ -999,6 +1000,16 @@ extension CardReaderServiceError: CardPaymentErrorProtocol { } } + var requiresFallbackPaymentMethod: Bool { + switch self { + case .paymentCaptureWithPaymentMethod(.paymentDeclinedByPaymentProcessorAPI(declineReason: .pinRequired), _), + .paymentCapture(.paymentDeclinedByPaymentProcessorAPI(declineReason: .pinRequired)): + return true + default: + return false + } + } + private func canRetryPayment(underlyingError: UnderlyingError) -> Bool { switch underlyingError { case .notConnectedToReader, @@ -1022,6 +1033,15 @@ extension CollectOrderPaymentUseCaseError: CardPaymentErrorProtocol { return .reuseIntent } } + + var requiresFallbackPaymentMethod: Bool { + switch self { + case .alreadyRetried(let error as CardReaderServiceError): + return error.requiresFallbackPaymentMethod + default: + return false + } + } } enum CollectOrderPaymentReceiptError: Error { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSection.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSection.swift index 31c99926859..2e07190b3e9 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSection.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSection.swift @@ -1,12 +1,24 @@ import SwiftUI +import WooFoundation /// View model for `OrderCustomAmountsSection` that controls the visibility states of modals from various sources. final class OrderCustomAmountsSectionViewModel: ObservableObject { /// Defines whether the new custom amount modal is presented. @Published var showAddCustomAmount: Bool = false - /// Defines whether the new custom amount options dialog is presented. - @Published var showAddCustomAmountOptionsDialog: Bool = false + /// Defines whether the custom amount options dialog is presented. + @Published var showCustomAmountOptionsDialog: Bool = false + + let currencySettings: CurrencySettings + init(currencySettings: CurrencySettings) { + self.currencySettings = currencySettings + } + + /// The site's currency symbol. When we support creating orders in other currencies, + /// the appropriate code will need to be passed in from the EditableOrderViewModel. + var currencySymbol: String { + currencySettings.currencySymbol + } } struct OrderCustomAmountsSection: View { @@ -28,6 +40,10 @@ struct OrderCustomAmountsSection: View { @Environment(\.safeAreaInsets) private var safeAreaInsets: EdgeInsets + @Environment(\.dynamicTypeSize) private var dynamicTypeSize: DynamicTypeSize + + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + var body: some View { VStack { HStack { @@ -71,10 +87,10 @@ struct OrderCustomAmountsSection: View { .if(viewModel.customAmountRows.isEmpty, transform: { $0.padding([.leading, .trailing]) }) .if(!viewModel.customAmountRows.isEmpty, transform: { $0.padding() }) .background(Color(.listForeground(modal: true))) - .sheet(isPresented: $sectionViewModel.showAddCustomAmountOptionsDialog, onDismiss: onDismissOptionsDialog) { - optionsWithDetentsBottomSheetContent + .popover(isPresented: isCustomAmountOptionsPopoverPresented) { + optionsPopoverContent } - .sheet(isPresented: $viewModel.showEditCustomAmount, onDismiss: onDismissOptionsDialog) { + .sheet(isPresented: isCustomAmountOptionsSheetPresented) { optionsWithDetentsBottomSheetContent } .sheet(isPresented: $sectionViewModel.showAddCustomAmount, @@ -86,13 +102,75 @@ struct OrderCustomAmountsSection: View { }) } + // Computed bindings based on horizontalSizeClass + private var isCustomAmountOptionsPopoverPresented: Binding { + Binding( + get: { sectionViewModel.showCustomAmountOptionsDialog && horizontalSizeClass == .regular }, + set: { newValue in + if horizontalSizeClass == .regular { + sectionViewModel.showCustomAmountOptionsDialog = newValue + } + } + ) + } + + private var isCustomAmountOptionsSheetPresented: Binding { + Binding( + get: { sectionViewModel.showCustomAmountOptionsDialog && horizontalSizeClass == .compact }, + set: { newValue in + if horizontalSizeClass == .compact { + sectionViewModel.showCustomAmountOptionsDialog = newValue + } + } + ) + } + + @ViewBuilder private var optionsPopoverContent: some View { + optionsBottomSheetContent + .frame(width: optionsPopoverSize.width, height: optionsPopoverSize.height) + } + + private var optionsPopoverSize: CGSize { + switch dynamicTypeSize { + case .xSmall, .small: + return CGSize(width: 325, height: 175) + case .medium, .large: + return CGSize(width: 375, height: 175) + case .xLarge: + return CGSize(width: 400, height: 175) + case .xxLarge, .xxxLarge: + return CGSize(width: 400, height: 200) + case .accessibility1: + return CGSize(width: 475, height: 225) + case .accessibility2: + return CGSize(width: 550, height: 250) + case .accessibility3: + return CGSize(width: 550, height: 350) + case .accessibility4, .accessibility5: + return CGSize(width: 550, height: 400) + @unknown default: + return CGSize(width: 550, height: 350) + } + } + @ViewBuilder private var optionsWithDetentsBottomSheetContent: some View { - if #available(iOS 16.0, *) { - optionsBottomSheetContent - .presentationDetents([.height(218)]) - .presentationDragIndicator(.visible) - } else { - optionsBottomSheetContent + optionsBottomSheetContent + .presentationDetents(detentsForOptionsBottomSheet) + .presentationDragIndicator(.visible) + } + + private var detentsForOptionsBottomSheet: Set { + switch dynamicTypeSize { + case .xSmall, .small, .medium, .large: + return [.height(175), .medium] + case .xLarge, .xxLarge: + return [.height(200), .medium] + case .xxxLarge: + return [.height(230), .medium] + case .accessibility1, .accessibility2, .accessibility3, .accessibility4, .accessibility5: + return [.medium, .large] + @unknown default: + return [.large] } } @@ -100,41 +178,46 @@ struct OrderCustomAmountsSection: View { VStack (alignment: .leading, spacing: Layout.optionsBottomSheetContentVerticalSpacing) { Text(Localization.optionsDialogAddCustomAmountTitle) .subheadlineStyle() - .padding(.top, Layout.optionsBottomSheetContentTitleTopPadding) - .padding(.bottom, Layout.optionsBottomSheetContentTitleBottomPadding) - - HStack { - Text("$") - .frame(minWidth: Layout.optionsBottomSheetButtonSymbolWidth) - .padding(.trailing, Layout.optionsBottomSheetButtonSymbolTrailing) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, Layout.optionsBottomSheetContentVerticalSpacing) + .padding([.leading, .trailing], Layout.optionsBottomSheetPadding) - Button(Localization.optionsDialogFixedAmountButtonTitle) { + List { + Button { addCustomAmountOption = .fixedAmount showAddCustomAmountsAfterOptionsDialog() - + } label: { + optionLabel(symbol: sectionViewModel.currencySymbol, + title: Localization.optionsDialogFixedAmountButtonTitle) } - .bodyStyle() + .listRowSeparator(.hidden) .accessibilityIdentifier(Accessibility.fixedAmountIdentifier) - } - .padding(.bottom, Layout.optionsBottomSheetContentVerticalSpacing) - - HStack { - Text("%") - .frame(minWidth: Layout.optionsBottomSheetButtonSymbolWidth) - .padding(.trailing, Layout.optionsBottomSheetButtonSymbolTrailing) - Button(Localization.optionsDialogPercentageButtonTitle) { + Button { addCustomAmountOption = .orderTotalPercentage showAddCustomAmountsAfterOptionsDialog() + } label: { + optionLabel(symbol: "%", + title: Localization.optionsDialogPercentageButtonTitle) } - .bodyStyle() + .listRowSeparator(.hidden) .accessibilityIdentifier(Accessibility.percentageAmountIdentifier) } + .listStyle(.plain) + } + .padding([.top, .bottom], Layout.optionsBottomSheetPadding) + } - Spacer() + @ViewBuilder private func optionLabel(symbol: String, title: String) -> some View { + Label { + Text(title) + } icon: { + Text(symbol) } - .padding(.leading, Layout.optionsBottomSheetVerticalPadding) - .padding(.trailing, Layout.optionsBottomSheetVerticalPadding) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .bodyStyle() } } @@ -143,28 +226,18 @@ private extension OrderCustomAmountsSection { viewModel.onAddCustomAmountButtonTapped() } - func onDismissOptionsDialog() { - if showAddCustomAmountAfterOptionsDialog { - showAddCustomAmountAfterOptionsDialog = false - sectionViewModel.showAddCustomAmount = true - } - } - func showAddCustomAmountsAfterOptionsDialog() { - showAddCustomAmountAfterOptionsDialog = true - sectionViewModel.showAddCustomAmountOptionsDialog = false - viewModel.showEditCustomAmount = false + sectionViewModel.showCustomAmountOptionsDialog = false + sectionViewModel.showAddCustomAmount = true } } private extension OrderCustomAmountsSection { enum Layout { static let optionsBottomSheetContentVerticalSpacing: CGFloat = 16 - static let optionsBottomSheetContentTitleTopPadding: CGFloat = 30 static let optionsBottomSheetContentTitleBottomPadding: CGFloat = 8 - static let optionsBottomSheetButtonSymbolWidth: CGFloat = 20 - static let optionsBottomSheetButtonSymbolTrailing: CGFloat = 18 - static let optionsBottomSheetVerticalPadding: CGFloat = 16 + static let optionsBottomSheetSymbolLabelSpacing: CGFloat = 18 + static let optionsBottomSheetPadding: CGFloat = 16 static let rowHeight: CGFloat = 56 } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift index 3ac3cc0bdbc..298eef25424 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/EditableOrderViewModel.swift @@ -111,7 +111,7 @@ final class EditableOrderViewModel: ObservableObject { !featureFlagService.isFeatureFlagEnabled(.betterCustomerSelectionInOrder) } - var enableAddingCustomAmountViaOrderTotalPercentage: Bool { + var orderIsNotEmpty: Bool { orderSynchronizer.order.items.isNotEmpty || orderSynchronizer.order.fees.isNotEmpty } @@ -149,7 +149,7 @@ final class EditableOrderViewModel: ObservableObject { /// When the value is non-nil, the bundle product configuration screen is shown. @Published var productToConfigureViewModel: ConfigurableBundleProductViewModel? - @Published private(set) var customAmountsSectionViewModel: OrderCustomAmountsSectionViewModel = .init() + @Published private(set) var customAmountsSectionViewModel: OrderCustomAmountsSectionViewModel // MARK: Status properties @@ -192,10 +192,6 @@ final class EditableOrderViewModel: ObservableObject { /// @Published private var storedTaxRate: TaxRate? = nil - /// Display the custom amount screen to edit it - /// - @Published var showEditCustomAmount: Bool = false - /// Defines if the toggle to store the tax rate in the selector should be enabled by default /// var shouldStoreTaxRateInSelectorByDefault: Bool { @@ -491,6 +487,7 @@ final class EditableOrderViewModel: ObservableObject { self.initialCustomer = initialCustomer self.barcodeScannerItemFinder = BarcodeScannerItemFinder(stores: stores) self.quantityDebounceDuration = quantityDebounceDuration + self.customAmountsSectionViewModel = OrderCustomAmountsSectionViewModel(currencySettings: currencySettings) // Set a temporary initial view model, as a workaround to avoid making it optional. // Needs to be reset before the view model is used. @@ -1026,8 +1023,11 @@ final class EditableOrderViewModel: ObservableObject { /// Starts the flow to add a custom amount. func addCustomAmount() { editingFee = nil - enableAddingCustomAmountViaOrderTotalPercentage ? - customAmountsSectionViewModel.showAddCustomAmountOptionsDialog.toggle() : customAmountsSectionViewModel.showAddCustomAmount.toggle() + if orderIsNotEmpty { + customAmountsSectionViewModel.showCustomAmountOptionsDialog = true + } else { + customAmountsSectionViewModel.showAddCustomAmount = true + } } func onCreateOrderTapped() { @@ -1550,6 +1550,13 @@ private extension EditableOrderViewModel { syncRequired = false } + func isSyncRequired(products: [Product], variations: [ProductVariation]) -> Bool { + let addedItemsToSync = productInputAdditionsToSync(products: products, variations: variations) + let removedItemsToSync = productInputDeletionsToSync(products: products, variations: variations) + + return (addedItemsToSync + removedItemsToSync).isNotEmpty + } + /// Adds a selected product (from the product list) to the order. /// func changeSelectionStateForProduct(_ product: Product, to isSelected: Bool) { @@ -1611,7 +1618,7 @@ private extension EditableOrderViewModel { onEditCustomAmount: { self.analytics.track(.orderCreationEditCustomAmountTapped) self.editingFee = fee - self.showEditCustomAmount = true + self.customAmountsSectionViewModel.showCustomAmountOptionsDialog = true }) } } @@ -2012,7 +2019,7 @@ private extension EditableOrderViewModel { case .immediate: syncOrderItems(products: selectedProducts, variations: selectedProductVariations) case .onRecalculateButtonTap: - syncRequired = true + syncRequired = isSyncRequired(products: selectedProducts, variations: selectedProductVariations) case .onSelectorButtonTap: return } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailView.swift index c8d286fa862..3788b49a915 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailView.swift @@ -1,8 +1,10 @@ import SwiftUI import Yosemite +import WooFoundation struct ReceiptEmailView: View { @ObservedObject var viewModel: ReceiptEmailViewModel + @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { @@ -39,10 +41,20 @@ struct ReceiptEmailView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button(Localization.cancel, action: { - viewModel.onDismiss(nil) + dismiss() }) } } + .onChange(of: viewModel.state) { state in + if state == .success { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + dismiss() + } + } + } + .onDisappear { + viewModel.onDisappear() + } } } } @@ -84,33 +96,3 @@ private enum Localization { comment: "Notice text when the merchant enters an invalid email" ) } - -final class ReceiptEmailViewHostingController: UIHostingController, UIAdaptivePresentationControllerDelegate { - private var onDismiss: ((Order?) -> Void) - - init(order: Order, - stores: StoresManager = ServiceLocator.stores, - systemNoticePresenter: NoticePresenter = ServiceLocator.noticePresenter, - onDismiss: @escaping (Order?) -> Void) { - - self.onDismiss = onDismiss - let viewModel = ReceiptEmailViewModel(order: order, stores: stores, onDismiss: onDismiss) - super.init(rootView: ReceiptEmailView(viewModel: viewModel)) - - viewModel.onDismiss = { [weak self] order in - self?.dismiss(animated: true, completion: nil) - onDismiss(order) - } - - presentationController?.delegate = self - viewModel.noticePresenter.presentingViewController = self - } - - required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { - onDismiss(nil) - } -} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailViewModel.swift index cd7df280a4c..5ca9a71d3c4 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Receipts/ReceiptEmail/ReceiptEmailViewModel.swift @@ -1,25 +1,32 @@ import Foundation import class WordPressShared.EmailFormatValidator import Yosemite +import WooFoundation +import Combine + +enum ReceiptEmailResult { + case success(Order) + case failure(Error) + case canceled +} final class ReceiptEmailViewModel: ObservableObject { @Published var email: String = "" - @Published var state: PrimaryLoadingButtonStyle.State = .idle + @Published private(set) var state: PrimaryLoadingButtonStyle.State = .idle - private let order: Order + private var order: Order private let stores: StoresManager - var noticePresenter: NoticePresenter - var emailValidator: (String) -> Bool = EmailFormatValidator.validate - var onDismiss: (Order?) -> Void + private let emailValidator: (String) -> Bool + private let onResult: (ReceiptEmailResult) -> Void init(order: Order, - stores: StoresManager, - noticesPresenter: NoticePresenter = DefaultNoticePresenter(), - onDismiss: @escaping (Order?) -> Void = { _ in }) { + stores: StoresManager = ServiceLocator.stores, + emailValidator: @escaping (String) -> Bool = EmailFormatValidator.validate, + onResult: @escaping (ReceiptEmailResult) -> Void) { self.order = order self.stores = stores - self.noticePresenter = noticesPresenter - self.onDismiss = onDismiss + self.emailValidator = emailValidator + self.onResult = onResult } var isEmailValid: Bool { @@ -34,27 +41,25 @@ final class ReceiptEmailViewModel: ObservableObject { self.state = .idle switch result { case let .success(order): + self.order = order self.state = .success - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - self.onDismiss(order) - } case let .failure(error): DDLogError("Sending email receipt failed: \(error.localizedDescription)") - self.noticePresenter.enqueue(notice: Notice(title: Localization.errorNotice, feedbackType: .error)) - } + self.onResult(.failure(error)) + } } } - self.state = .loading + state = .loading stores.dispatch(action) } -} - -private enum Localization { - static let errorNotice = NSLocalizedString( - "order.receiptEmailView.errorNotice", - value: "Error sending the email receipt. Please try again.", - comment: "An error that is shown when sending email receipt fails." - ) + func onDisappear() { + switch state { + case .success: + onResult(.success(order)) + default: + onResult(.canceled) + } + } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Customs/ItemDetails/ShippingLabelCustomsFormItemDetails.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Customs/ItemDetails/ShippingLabelCustomsFormItemDetails.swift index 9e7ffe8a7f9..3acedc7dd6a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Customs/ItemDetails/ShippingLabelCustomsFormItemDetails.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/Customs/ItemDetails/ShippingLabelCustomsFormItemDetails.swift @@ -181,7 +181,7 @@ private extension ShippingLabelCustomsFormItemDetails { enum Constants { static let horizontalSpacing: CGFloat = 16 static let verticalSpacing: CGFloat = 8 - static let hsTariffURL: URL? = .init(string: "https://woocommerce.com/document/woocommerce-shipping-and-tax/woocommerce-shipping/#section-29") + static let hsTariffURL = WooConstants.URLs.hsTariffURL.asURL() } enum Localization { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewModel.swift index b0f4c6dae4e..c26669a7884 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/Create Shipping Label Form/ShippingLabelFormViewModel.swift @@ -43,7 +43,7 @@ final class ShippingLabelFormViewModel { /// Customs forms /// - private (set) var customsForms: [ShippingLabelCustomsForm] = [] + private(set) var customsForms: [ShippingLabelCustomsForm] = [] /// Carrier and Rates /// diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift new file mode 100644 index 00000000000..6830410b9ed --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsForm.swift @@ -0,0 +1,213 @@ +import SwiftUI +import WooFoundation +import Yosemite + +struct WooShippingCustomsForm: View { + @Environment(\.presentationMode) var presentationMode + @ObservedObject var viewModel: WooShippingCustomsFormViewModel + @State private var isShowingITNInfoWebView = false + + private var contentTypeSelectionView: some View { + Menu { + // show selection + ForEach(WooShippingContentType.allCases, id: \.self) { option in + Button { + } label: { + Text(option.name) + .bodyStyle() + if viewModel.contentType == option { + Image(uiImage: .checkmarkStyledImage) + } + } + } + } label: { + HStack { + Text(viewModel.contentType.name) + .bodyStyle() + Spacer() + Image(systemName: "chevron.up.chevron.down") + } + .padding() + } + .roundedBorder(cornerRadius: Constants.borderCornerRadius, lineColor: Color(.separator), lineWidth: Constants.borderWidth) + } + + private var restrictionTypeSelectionView: some View { + Menu { + // show selection + ForEach(WooShippingRestrictionType.allCases, id: \.self) { option in + Button { + } label: { + Text(option.name) + .bodyStyle() + if viewModel.restrictionType == option { + Image(uiImage: .checkmarkStyledImage) + } + } + } + } label: { + HStack { + Text(viewModel.restrictionType.name) + .bodyStyle() + Spacer() + Image(systemName: "chevron.up.chevron.down") + } + .padding() + } + .roundedBorder(cornerRadius: Constants.borderCornerRadius, lineColor: Color(.separator), lineWidth: Constants.borderWidth) + } + + var body: some View { + NavigationView { + GeometryReader { geometry in + VStack { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: Constants.defaultVerticalSpacing) { + HStack { + Text(Localization.contentType) + .font(.subheadline) + Spacer() + } + + contentTypeSelectionView + .padding(.bottom, Constants.defaultVerticalSpacing) + + HStack { + Text(Localization.restrictionType) + .font(.subheadline) + Spacer() + } + + restrictionTypeSelectionView + .padding(.bottom, Constants.defaultVerticalSpacing) + + HStack { + Text(Localization.internationalTransactionNumber) + .font(.subheadline) + Spacer() + } + + TextField("", text: $viewModel.internationalTransactionNumber) + .padding(Constants.borderPadding) + .roundedBorder(cornerRadius: Constants.borderCornerRadius, lineColor: Color(.separator), lineWidth: Constants.borderWidth) + + Button { + isShowingITNInfoWebView = true + } label: { + HStack(alignment: .top, spacing: Constants.intoButtonHorizontalSpacing) { + Image(systemName: "info.circle") + Text(Localization.infoText) + } + .foregroundColor(Color(.wooCommercePurple(.shade60))) + .footnoteStyle() + .padding(.bottom, Constants.bottomButtonPadding) + } + + Toggle(isOn: $viewModel.returnToSenderIfNotDelivered) { + Text(Localization.returnToSenderMessage) + .font(.subheadline) + } + .tint(Color.accentColor) + .padding(.bottom, Constants.returnToSenderRowBottomPadding) + + Text(Localization.productDetailsTitle) + .tertiaryTitleStyle() + .padding(.bottom, Constants.defaultVerticalSpacing) + + // Dummy data + WooShippingCustomsItem(viewModel: WooShippingCustomsItemViewModel( + title: "Little Nap Brazil 250g", + description: "Coffee Beans", + hsTariffNumber: "HS 14-1", + valuePerUnit: "$20.00", + weightPerUnit: "0.3kg", + originCountry: WooShippingCustomsCountry(code: "US", name: "United States")) + ) + } + .padding() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(action: { + presentationMode.wrappedValue.dismiss() + }, label: { + Text(Localization.cancel) + }) + } + } + .navigationTitle(Localization.customs) + .navigationBarTitleDisplayMode(.inline) + + Spacer() + } + .navigationViewStyle(.stack) + .safariSheet(isPresented: $isShowingITNInfoWebView, url: viewModel.itnInfoURL) + } + + Spacer() + + Divider() + + Button { + // TODO: Save values + presentationMode.wrappedValue.dismiss() + } label: { + Text(viewModel.informationIsMissing ? Localization.addMissingInformationButtonTitle : Localization.saveCustomsDetailsButtonTitle) + } + .buttonStyle(PrimaryButtonStyle()) + .disabled(viewModel.informationIsMissing) + .padding(Constants.bottomButtonPadding) + } + } + + } + } +} + +extension WooShippingCustomsForm { + enum Localization { + static let cancel = NSLocalizedString("wooShipping.customs.cancel", + value: "Cancel", + comment: "Cancel button in navigation bar to dismiss the screen") + static let customs = NSLocalizedString("wooShipping.customs.title", + value: "Customs", + comment: "Title for the Customs screen") + static let contentType = NSLocalizedString("wooShipping.customs.contentType", + value: "Content Type", + comment: "Title for the Content Type menu in the Shipping Customs Form") + static let restrictionType = NSLocalizedString("wooShipping.customs.restrictionType", + value: "Restriction Type", + comment: "Title for the Restriction Type menu in the Shipping Customs Form") + static let internationalTransactionNumber = NSLocalizedString("wooShipping.customs.internationalTransactionNumber", + value: "International Transaction Number", + comment: "Title for the Internaction Transaction Number in the Shipping Customs Form") + static let infoText = NSLocalizedString("wooShipping.customs.internationalTransactionNumber.infoText", + value: "More info about ITN", + comment: "Explanatory text for the international transaction number in customs") + static let returnToSenderMessage = NSLocalizedString("wooShipping.customs.returnToSenderMessage", + value: "Return to sender if package is not able to be delivered", + comment: "Info label for a toggle to return the package to a sender if necessary toggle") + static let addMissingInformationButtonTitle = NSLocalizedString("wooShipping.customs.addMissingInformationButtonTitle", + value: "Add Missing Information", + comment: "Customs button title when it's disabled and there's still info to add") + static let saveCustomsDetailsButtonTitle = NSLocalizedString("wooShipping.customs.saveCustomsDetails", + value: "Save Customs Details", + comment: "Customs button title when it's enabled and there's no info to add") + static let productDetailsTitle = NSLocalizedString("wooShipping.customs.productDetails", + value: "Product Details", + comment: "Product Details Section title") + } + +} + +extension WooShippingCustomsForm { + enum Constants { + static let defaultVerticalSpacing: CGFloat = 8.0 + static let borderCornerRadius: CGFloat = 8 + static let borderWidth: CGFloat = 1 + static let borderPadding: CGFloat = 16 + static let intoButtonHorizontalSpacing: CGFloat = 8 + static let bottomButtonPadding: CGFloat = 16.0 + static let returnToSenderRowBottomPadding: CGFloat = 32.0 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift new file mode 100644 index 00000000000..68b004cd6db --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsFormViewModel.swift @@ -0,0 +1,105 @@ +import SwiftUI + +final class WooShippingCustomsFormViewModel: ObservableObject { + @Published var internationalTransactionNumber: String + @Published var returnToSenderIfNotDelivered: Bool + + var informationIsMissing: Bool { + false + } + + let contentType: WooShippingContentType = .merchandise + let restrictionType: WooShippingRestrictionType = .none + + let itnInfoURL = URL(string: "https://pe.usps.com/text/imm/immc5_010.htm") + + init(internationalTransactionNumber: String, returnToSenderIfNotDelivered: Bool) { + self.internationalTransactionNumber = internationalTransactionNumber + self.returnToSenderIfNotDelivered = returnToSenderIfNotDelivered + } +} + +enum WooShippingRestrictionType: String, CaseIterable { + case none + case quarantine + case sanitary + case other + var name: String { + switch self { + case .none: + return Localization.none + case .quarantine: + return Localization.quarantine + case .sanitary: + return Localization.sanitary + case .other: + return Localization.other + + } + } +} + +extension WooShippingRestrictionType { + enum Localization { + static let none = NSLocalizedString("wooShipping.customs.restrictionType.none", + value: "None", + comment: "Info label for shipping restriction type none") + static let quarantine = NSLocalizedString("wooShipping.customs.restrictionType.quarantine", + value: "Quarantine", + comment: "Info label for shipping restriction type quarantine") + static let sanitary = NSLocalizedString("wooShipping.customs.restrictionType.sanitary", + value: "Sanitary/Phitosanitary Inspection", + comment: "Info label for shipping restriction type sanitary") + static let other = NSLocalizedString("wooShipping.customs.restrictionType.other", + value: "Other", + comment: "Info label for shipping restriction type other") + } +} + +enum WooShippingContentType: String, CaseIterable { + case merchandise + case gift + case returnedGoods + case sample + case documents + case other + var name: String { + switch self { + case .merchandise: + return Localization.merchandise + case .returnedGoods: + return Localization.returnedGoods + case .documents: + return Localization.documents + case .gift: + return Localization.gift + case .sample: + return Localization.sample + case .other: + return Localization.other + } + } +} + +extension WooShippingContentType { + enum Localization { + static let merchandise = NSLocalizedString("wooShipping.customs.contentType.merchandise", + value: "Merchandise", + comment: "Info label for shipping content type merchandise") + static let returnedGoods = NSLocalizedString("wooShipping.customs.contentType.returnedGoods", + value: "Returned Goods", + comment: "Info label for shipping content type returned goods") + static let documents = NSLocalizedString("wooShipping.customs.contentType.documents", + value: "Documents", + comment: "Info label for shipping content type merchandise") + static let gift = NSLocalizedString("wooShipping.customs.contentType.gift", + value: "Gift", + comment: "Info label for shipping content type merchandise") + static let sample = NSLocalizedString("wooShipping.customs.contentType.sample", + value: "Sample", + comment: "Info label for shipping content type merchandise") + static let other = NSLocalizedString("wooShipping.customs.contentType.other", + value: "Other...", + comment: "Info label for shipping content type merchandise") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift new file mode 100644 index 00000000000..6d06ba8961e --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItem.swift @@ -0,0 +1,235 @@ +import SwiftUI +import WooFoundation + +struct WooShippingCustomsItem: View { + /// Whether the item list is collapsed + @State private var isCollapsed: Bool = true + @ObservedObject var viewModel: WooShippingCustomsItemViewModel + @State private var isShowingHSTarrifInfoWebView = false + @State private var isShowingCountries = false + @State private var isShowingDescriptionInfoDialog = false + @State private var isShowingOriginCountryInfoDialog = false + + + @Environment(\.shippingWeightUnit) var weightUnit: String + + var body: some View { + CollapsibleView(isCollapsed: $isCollapsed, + shouldShowDividers: false, + backgroundColor: .clear, + label: { + VStack(alignment: .leading, spacing: Layout.collapsibleViewVerticalSpacing) { + HStack { + Text(viewModel.title) + .headlineStyle() + Spacer() + Image(systemName: "exclamationmark.circle") + .foregroundColor(.withColorStudio(name: .red, shade: .shade60)) + .renderedIf(viewModel.informationIsMissing) + } + + VStack(alignment: .leading, spacing: Layout.collapsibleViewBottomLabelVerticalSpacing) { + HStack { + Text(viewModel.description) + Spacer() + Text(viewModel.hsTariffNumber) + } + HStack { + Text(viewModel.originCountry.name) + Spacer() + Text(viewModel.weightPerUnit) + Text("•") + Text(viewModel.valuePerUnit) + } + }.renderedIf(isCollapsed) + .foregroundColor(.primary) + .padding(.trailing, Layout.collapsibleViewBottomContentTrailingPadding) + } + .padding(.top, Layout.collapsibleViewTopPadding) + }, content: { + VStack(alignment: .leading, spacing: Layout.collapsibleViewVerticalSpacing) { + Divider() + HStack { + Text(Localization.descriptionTitle) + .foregroundColor(.primary) + .subheadlineStyle() + Spacer() + Button { + isShowingDescriptionInfoDialog = true + } label: { + Image(systemName: "info.circle") + .foregroundColor(Color(.wooCommercePurple(.shade60))) + + } + } + .padding(.top, Layout.descriptionTopPadding) + TextField("", text: $viewModel.description) + .padding(Layout.extraPadding) + .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.separator), lineWidth: Layout.borderLineWidth) + .padding(.bottom, Layout.collapsibleViewVerticalSpacing) + Text(Localization.HSTariffNumber) + .foregroundColor(.primary) + .subheadlineStyle() + TextField(Localization.HSTariffNumberPlaceholder, text: $viewModel.hsTariffNumber) + .padding(Layout.extraPadding) + .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.separator), lineWidth: Layout.borderLineWidth) + + Button { + isShowingHSTarrifInfoWebView = true + } label: { + HStack(alignment: .top, spacing: Layout.hsTariffNumberMoreInfoVerticalSpacing) { + Image(systemName: "info.circle") + Text(Localization.HSTariffNumberMoreInfo) + } + .foregroundColor(Color(.wooCommercePurple(.shade60))) + .footnoteStyle() + .padding(.bottom, Layout.collapsibleViewVerticalSpacing) + } + + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text(Localization.valuePerUnitTitle) + .foregroundColor(.primary) + .subheadlineStyle() + TextField("$ 0", text: $viewModel.valuePerUnit) + .padding(Layout.extraPadding) + .roundedBorder(cornerRadius: Layout.borderCornerRadius, + lineColor: viewModel.valuePerUnit.isEmpty ? .withColorStudio(name: .red, shade: .shade60) : Color(.separator), + lineWidth: Layout.borderLineWidth) + Text(Localization.valueRequiredWarningText) + .foregroundColor(.withColorStudio(name: .red, shade: .shade60)) + .footnoteStyle() + .renderedIf(viewModel.valuePerUnit.isEmpty) + } + + VStack(alignment: .leading) { + Text(Localization.weightPerUnitTitle) + .foregroundColor(.primary) + .subheadlineStyle() + HStack { + TextField("0", text: $viewModel.weightPerUnit) + .padding(Layout.extraPadding) + Text(weightUnit) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding(.trailing, Layout.unitsHorizontalSpacing) + } + .roundedBorder(cornerRadius: Layout.borderCornerRadius, + lineColor: viewModel.weightPerUnit.isEmpty ? .withColorStudio(name: .red, shade: .shade60) : Color(.separator), + lineWidth: Layout.borderLineWidth) + Text(Localization.valueRequiredWarningText) + .foregroundColor(.withColorStudio(name: .red, shade: .shade60)) + .footnoteStyle() + .renderedIf(viewModel.weightPerUnit.isEmpty) + } + } + .padding(.bottom, Layout.collapsibleViewVerticalSpacing) + + HStack { + Text(Localization.originCountryTitle) + .foregroundColor(.primary) + Spacer() + Button { + isShowingOriginCountryInfoDialog = true + } label: { + Image(systemName: "info.circle") + .foregroundColor(Color(.wooCommercePurple(.shade60))) + + } + } + .subheadlineStyle() + + Button { + isShowingCountries = true + } label: { + HStack { + Text(viewModel.originCountry.name) + .bodyStyle() + Spacer() + Image(systemName: "chevron.up.chevron.down") + } + .padding() + } + .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.separator), lineWidth: Layout.borderLineWidth) + } + .padding(.leading, Layout.extraPadding) + .padding(.trailing, Layout.extraPadding) + .padding(.bottom, Layout.extraPadding) + }) + .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: productCardBorderColor(), lineWidth: Layout.borderLineWidth) + .safariSheet(isPresented: $isShowingHSTarrifInfoWebView, url: viewModel.hsTariffURL) + .sheet(isPresented: $isShowingCountries, content: { + NavigationStack { + SingleSelectionList(title: Localization.originCountryTitle, + items: viewModel.countries, + contentKeyPath: \.name, + selected: $viewModel.originCountry) + } + .wooNavigationBarStyle() + }) + .fullScreenCover(isPresented: $isShowingDescriptionInfoDialog) { + WooShippingCustomsItemDescriptionInfoDialog() + .background(FullScreenCoverClearBackgroundView()) + } + .fullScreenCover(isPresented: $isShowingOriginCountryInfoDialog) { + WooShippingCustomsItemOriginCountryInfoDialog() + .background(FullScreenCoverClearBackgroundView()) + } + } +} + +extension WooShippingCustomsItem { + func productCardBorderColor() -> Color { + if isCollapsed { + if viewModel.informationIsMissing { + return .withColorStudio(name: .red, shade: .shade60) + } else { + return Color(.separator) + } + } else { + return .withColorStudio(name: .purple, shade: .shade60) + } + } +} + +extension WooShippingCustomsItem { + enum Localization { + static let descriptionTitle = NSLocalizedString("wooShipping.customsItems.description", + value: "Description", + comment: "Title for the customs items description text field for customs items") + static let HSTariffNumber = NSLocalizedString("wooShipping.customsItems.hsTariffNumber", + value: "HS tariff number", + comment: "Title for the HS Tariff Number text field for customs items") + static let HSTariffNumberPlaceholder = NSLocalizedString("wooShipping.customsItems.hsTariffNumber.placeholder", + value: "Optional", + comment: "Placeholder for the HS Tariff Number text field for customs items") + static let HSTariffNumberMoreInfo = NSLocalizedString("wooShipping.customsItems.hsTariffNumber.moreInfoText", + value: "More info about HS tariff", + comment: "Information text about the HS Tariff") + static let valuePerUnitTitle = NSLocalizedString("wooShipping.customsItems.valuePerUnit", + value: "Value per unit", + comment: "Title for the customs items value per unit text field for customs items") + static let weightPerUnitTitle = NSLocalizedString("wooShipping.customsItems.weightPerUnit", + value: "Weight per unit", + comment: "Title for the customs items weight per unit text field for customs items") + static let valueRequiredWarningText = NSLocalizedString("wooShipping.customsItems.valueRequired", + value: "Value required", + comment: "Warning text when some required value is missing") + static let originCountryTitle = NSLocalizedString("wooShipping.customsItems.originCountry", + value: "Origin Country", + comment: "Title for the origin country text field") + } + + enum Layout { + static let collapsibleViewTopPadding: CGFloat = 4.0 + static let collapsibleViewBottomContentTrailingPadding: CGFloat = -30.0 + static let collapsibleViewVerticalSpacing: CGFloat = 8.0 + static let collapsibleViewBottomLabelVerticalSpacing: CGFloat = 4.0 + static let descriptionTopPadding: CGFloat = 4.0 + static let borderCornerRadius: CGFloat = 8.0 + static let borderLineWidth: CGFloat = 1.0 + static let extraPadding: CGFloat = 16.0 + static let hsTariffNumberMoreInfoVerticalSpacing: CGFloat = 8.0 + static let unitsHorizontalSpacing: CGFloat = 8.0 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemDescriptionInfoDialog.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemDescriptionInfoDialog.swift new file mode 100644 index 00000000000..5e64772df2b --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemDescriptionInfoDialog.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct WooShippingCustomsItemDescriptionInfoDialog: View { + /// Scale of the view based on accessibility changes + @ScaledMetric private var scale: CGFloat = 1.0 + + @Environment(\.dismiss) var dismiss + + /// Whether the learn more webview is being shown. + @State private var showLearnMoreWebView: Bool = false + + var body: some View { + ZStack { + Color.black.opacity(Layout.backgroundOpacity).edgesIgnoringSafeArea(.all) + + VStack { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .center, spacing: Layout.verticalSpacing) { + Text(Localization.title) + .headlineStyle() + Text(Localization.bodyParagraph) + .bodyStyle() + .fixedSize(horizontal: false, vertical: true) + + Button { + showLearnMoreWebView = true + } label: { + Label { + Text(Localization.learnMoreButtonTitle) + .font(.body) + .fontWeight(.bold) + } icon: { + Image(systemName: "arrow.up.forward.square") + .resizable() + .frame(width: Layout.externalLinkImageSize * scale, height: Layout.externalLinkImageSize * scale) + } + } + .buttonStyle(PrimaryButtonStyle()) + .safariSheet(isPresented: $showLearnMoreWebView, url: WooConstants.URLs.shippingCustomsInstructionsForEUCountries.asURL()) + + Button { + dismiss() + } label: { + Text(Localization.doneButtonTitle) + } + .buttonStyle(SecondaryButtonStyle()) + } + .padding(Layout.outterPadding) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(.systemBackground)) + .cornerRadius(Layout.cornerRadius) + .frame(width: geometry.size.width) + .frame(minHeight: geometry.size.height) + } + } + } + .padding(Layout.outterPadding) + .frame(maxWidth: .infinity, alignment: .center) + } + } +} +extension WooShippingCustomsItemDescriptionInfoDialog { + enum Localization { + static let title = NSLocalizedString("shipping.customs.descriptionInfoDialogTitle", + value: "Description", + comment: "Title for the custom description educational dialog") + static let bodyParagraph = NSLocalizedString("shipping.customs.descriptionInfoDialogBody", + value: "When shipping to countries that follow European Union (EU) customs rules, " + + "you must provide a clear, specific description on every item. " + + "For example, if you are sending clothing, you must indicate what type of clothing" + + " (e.g. men's shirts, girl's vest, boy's jacket) for the description to be acceptable." + + " Otherwise, shipments may be delayed or interrupted at customs.", + comment: "Body for the custom items description educational dialog") + static let learnMoreButtonTitle = NSLocalizedString("shipping.customs.descriptionInfoDialogLearnMore", + value: "Learn more", + comment: "Button title for the learn more action in the custom descriptions info dialog") + static let doneButtonTitle = NSLocalizedString("shipping.customs.descriptionInfoDialogDone", + value: "Done", + comment: "Button title for the done button in the customs description educational dialog") + } + enum Layout { + static let backgroundOpacity: CGFloat = 0.5 + static let externalLinkImageSize: CGFloat = 18 + static let verticalSpacing: CGFloat = 16 + static let outterPadding: CGFloat = 24 + static let cornerRadius: CGFloat = 8 + static let dividerHeight: CGFloat = 1 + static let taxLinesInnerSpacing: CGFloat = 4 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemOriginCountryInfoDialog.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemOriginCountryInfoDialog.swift new file mode 100644 index 00000000000..d60251c7229 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemOriginCountryInfoDialog.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct WooShippingCustomsItemOriginCountryInfoDialog: View { + /// Scale of the view based on accessibility changes + @ScaledMetric private var scale: CGFloat = 1.0 + + @Environment(\.dismiss) var dismiss + + var body: some View { + ZStack { + Color.black.opacity(Layout.backgroundOpacity).edgesIgnoringSafeArea(.all) + + VStack { + GeometryReader { geometry in + ScrollView { + VStack(alignment: .center, spacing: Layout.verticalSpacing) { + Text(Localization.title) + .headlineStyle() + Text(Localization.bodyParagraph) + .bodyStyle() + .fixedSize(horizontal: false, vertical: true) + Button { + dismiss() + } label: { + Text(Localization.doneButtonTitle) + } + .buttonStyle(PrimaryButtonStyle()) + } + .padding(Layout.outterPadding) + .frame(maxWidth: .infinity, alignment: .center) + .background(Color(.systemBackground)) + .cornerRadius(Layout.cornerRadius) + .frame(width: geometry.size.width) + .frame(minHeight: geometry.size.height) + } + } + } + .padding(Layout.outterPadding) + .frame(maxWidth: .infinity, alignment: .center) + } + } +} +extension WooShippingCustomsItemOriginCountryInfoDialog { + enum Localization { + static let title = NSLocalizedString("shipping.customs.originCountryInfoDialogTitle", + value: "Origin Country", + comment: "Title for the custom origin country educational dialog") + static let bodyParagraph = NSLocalizedString("shipping.customs.originCountryInfoDialogBody", + value: "Country where the product was manufactured or assembled.", + comment: "Body for the custom items origin country educational dialog") + static let doneButtonTitle = NSLocalizedString("shipping.customs.originCountryInfoDialogDoneButton", + value: "Done", + comment: "Button title for the done button in the customs description educational dialog") + } + enum Layout { + static let backgroundOpacity: CGFloat = 0.5 + static let externalLinkImageSize: CGFloat = 18 + static let verticalSpacing: CGFloat = 16 + static let outterPadding: CGFloat = 24 + static let cornerRadius: CGFloat = 8 + static let dividerHeight: CGFloat = 1 + static let taxLinesInnerSpacing: CGFloat = 4 + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift new file mode 100644 index 00000000000..b8ac76d76c6 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsItemViewModel.swift @@ -0,0 +1,77 @@ +import Yosemite +import SwiftUI +import protocol Storage.StorageManagerType + +struct WooShippingCustomsCountry: Hashable { + let code: String + let name: String +} + +final class WooShippingCustomsItemViewModel: ObservableObject { + @Published var title: String + @Published var description: String + @Published var hsTariffNumber: String + @Published var valuePerUnit: String + @Published var weightPerUnit: String + @Published var originCountry: WooShippingCustomsCountry + + var informationIsMissing: Bool = true + + private let storageManager: StorageManagerType + private let stores: StoresManager + private let siteID: Int64 + + private lazy var resultsController: ResultsController = { + let descriptor = NSSortDescriptor(key: "name", ascending: true) + return ResultsController(storageManager: storageManager, matching: nil, sortedBy: [descriptor]) + }() + + var countries: [WooShippingCustomsCountry] { + let countries = resultsController.fetchedObjects + + // This removes the states property because: + // - It's not necessary to display the list + // - As we retrieve a different order on the states array property from the ResultsController, it might mess the Equality comparison + return countries.map { WooShippingCustomsCountry(code: $0.code, name: $0.name) } + } + + let hsTariffURL = WooConstants.URLs.hsTariffURL.asURL() + + init(title: String, + description: String, + hsTariffNumber: String, + valuePerUnit: String, + weightPerUnit: String, + originCountry: WooShippingCustomsCountry, + storageManager: StorageManagerType = ServiceLocator.storageManager, + stores: StoresManager = ServiceLocator.stores) { + self.title = title + self.description = description + self.hsTariffNumber = hsTariffNumber + self.valuePerUnit = valuePerUnit + self.weightPerUnit = weightPerUnit + self.originCountry = originCountry + self.storageManager = storageManager + self.stores = stores + self.siteID = stores.sessionManager.defaultStoreID ?? Int64.min + + fetchCountries() + } +} + +extension WooShippingCustomsItemViewModel { + func fetchCountries() { + try? resultsController.performFetch() + let action = DataAction.synchronizeCountries(siteID: siteID) { [weak self] (result) in + guard let self = self else { return } + switch result { + case .success: + try? self.resultsController.performFetch() + case .failure: + break + } + } + + stores.dispatch(action) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsRow.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsRow.swift new file mode 100644 index 00000000000..59335084195 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Customs/WooShippingCustomsRow.swift @@ -0,0 +1,73 @@ +import SwiftUI +import WooFoundation + +struct WooShippingCustomsRow: View { + let informationIsCompleted: Bool + @ScaledMetric private var scale: CGFloat = 1.0 + @State private var showCustomsForm: Bool = false + + var body: some View { + AdaptiveStack { + Text(Localization.customsTitle) + .headlineStyle() + .foregroundColor(.primary) + + Spacer() + + Text(informationIsCompleted ? Localization.completedStatus : Localization.missingInfoStatus) + .captionStyle() + .foregroundColor(.black) + .padding(.horizontal, Layout.statusBadgeHorizontalPadding) + .padding(.vertical, Layout.statusBadgeVerticalPadding) + .background( + RoundedRectangle(cornerRadius: Layout.statusBadgeCornerRadius) + .fill(informationIsCompleted ? + Color.withColorStudio(name: .green, shade: .shade5) : + Color.withColorStudio(name: .red, shade: .shade10)) + ) + .padding(.horizontal, 10) + + PencilEditButton { + showCustomsForm.toggle() + } + .accessibilityLabel(Text(Localization.editButtonAccessibilityLabel)) + } + .padding(Layout.borderPadding) + .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.separator), lineWidth: Layout.borderWidth) + .sheet(isPresented: $showCustomsForm) { + WooShippingCustomsForm(viewModel: WooShippingCustomsFormViewModel(internationalTransactionNumber: "123", + returnToSenderIfNotDelivered: true)) + } + } +} + +private extension WooShippingCustomsRow { + enum Layout { + static let borderCornerRadius: CGFloat = 8 + static let borderWidth: CGFloat = 0.5 + static let pencilButtonSizeDimensions: CGFloat = 22 + static let customsTitleFontSize: CGFloat = 17 + static let statusBadgeFontSize: CGFloat = 14 + static let statusBadgeCornerRadius: CGFloat = 6 + static let statusBadgeHorizontalPadding: CGFloat = 12 + static let statusBadgeVerticalPadding: CGFloat = 6 + static let borderPadding: CGFloat = 16 + } +} + +private extension WooShippingCustomsRow { + enum Localization { + static let customsTitle = NSLocalizedString("shippingLabels.customs.customsTitle", + value: "Customs", + comment: "Customs row title in the main Shipping Labels view") + static let completedStatus = NSLocalizedString("shippingLabels.customs.completedBadge", + value: "Completed", + comment: "Badge wording when the customs information is completed") + static let missingInfoStatus = NSLocalizedString("shippingLabels.customs.missingInfo", + value: "Missing info", + comment: "Badge wording when the customs information is missing") + static let editButtonAccessibilityLabel = NSLocalizedString("shippingLabels.customs.editButtonAccessibiliy", + value: "Edit Shipping Labels Customs Info", + comment: "Accessibility label for the button to edit the shipping labels customs") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/SelectedPackageView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/SelectedPackageView.swift deleted file mode 100644 index d8f6bcb20d1..00000000000 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/SelectedPackageView.swift +++ /dev/null @@ -1,76 +0,0 @@ -import SwiftUI - -struct SelectedPackageView: View { - let package: WooShippingPackageDataRepresentable - let weightUnit: String - @Binding var totalWeight: String - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text(Localization.package) - .headlineStyle() - Spacer() - PencilEditButton { - // TODO: Edit selected package - } - .buttonStyle(TextButtonStyle()) - } - PackageOptionView(package: package, - showTopDivider: false, - showSource: true, - tapAction: {}) - .roundedBorder(cornerRadius: Constants.cornerRadius, lineColor: Constants.lineColor, lineWidth: Constants.lineWidth) - .padding(.bottom) - shipmentWeight - } - } - - private var shipmentWeight: some View { - VStack(alignment: .leading) { - Text(Localization.totalWeight) - .bodyStyle() - HStack { - TextField("", text: $totalWeight) - .keyboardType(.decimalPad) - .bodyStyle() - Text(weightUnit) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding() - .roundedBorder(cornerRadius: Constants.cornerRadius, lineColor: Constants.lineColor, lineWidth: Constants.lineWidth) - } - } -} - -private extension SelectedPackageView { - enum Constants { - static let cornerRadius: CGFloat = 8 - static let lineColor = Color(.separator) - static let lineWidth: CGFloat = 0.5 - } - - enum Localization { - static let package = NSLocalizedString("wooShipping.createLabels.package.title", - value: "Package", - comment: "Heading for the package section in the shipping label creation screen.") - static let totalWeight = NSLocalizedString("wooShipping.createLabels.package.totalWeight", - value: "Total shipment weight (with package)", - comment: "Label for the total shipment weight input field in the shipping label creation screen.") - } -} - -#Preview { - SelectedPackageView(package: WooShippingPackageData(name: "Small Flat Rate Box", - length: "12", - width: "6", - height: "6", - dimensionsUnit: "in", - weight: "4", - weightUnit: "oz", - source: .predefined("USPS Priority Mail Flat Rate Boxes"), - packageType: "box"), - weightUnit: "oz", - totalWeight: .constant("6")) -} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooAddCustomPackageView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooAddCustomPackageView.swift index 9ebb74ae12f..8bac92acd49 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooAddCustomPackageView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooAddCustomPackageView.swift @@ -15,6 +15,9 @@ struct WooAddCustomPackageView: View { @State private var isSavingPackage: Bool = false @State private var isAddingPackage: Bool = false + @Environment(\.shippingDimensionsUnit) private var dimensionsUnit + @Environment(\.shippingWeightUnit) private var weightUnit + let addPackageAction: (WooShippingPackageDataRepresentable) -> Void init(viewModel: WooShippingAddCustomPackageViewModel, addPackageAction: @escaping (WooShippingPackageDataRepresentable) -> Void) { @@ -62,12 +65,12 @@ struct WooAddCustomPackageView: View { VStack { AdaptiveStack(spacing: 8) { ForEach(WooShippingPackageUnitType.dimensionUnits, id: \.self) { dimensionUnit in - unitInputView(for: dimensionUnit, unit: viewModel.dimensionsUnit) + unitInputView(for: dimensionUnit, unit: dimensionsUnit) } } // showing weight input only if we are saving the template if viewModel.showSaveTemplate { - unitInputView(for: WooShippingPackageUnitType.weight, unit: viewModel.weightUnit) + unitInputView(for: WooShippingPackageUnitType.weight, unit: weightUnit) } } .toolbar { @@ -126,14 +129,14 @@ struct WooAddCustomPackageView: View { } else { Spacer() - Button(WooShippingAddPackageView.Localization.addPackage) { + Button(selectionButtonText) { Task { @MainActor in isAddingPackage = true await addPackageButtonTapped() isAddingPackage = false } } - .disabled(!viewModel.validateCustomPackageInputFields()) + .disabled(selectionButtonDisabled) .buttonStyle(PrimaryLoadingButtonStyle(isLoading: isAddingPackage)) .padding(.bottom) } @@ -162,6 +165,17 @@ struct WooAddCustomPackageView: View { } } + private var selectionButtonDisabled: Bool { + !viewModel.validateCustomPackageInputFields() + } + + private var selectionButtonText: String { + if selectionButtonDisabled { + return WooShippingAddPackageView.Localization.addPackageDetails + } + return WooShippingAddPackageView.Localization.addPackage + } + private func unitInputView(for unitType: WooShippingPackageUnitType, unit: String) -> some View { WooShippingAddPackageUnitInputView(unitType: unitType, unit: unit, diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooCarrierPackagesSelectionView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooCarrierPackagesSelectionView.swift index 55322d80a3c..42037192d16 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooCarrierPackagesSelectionView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooCarrierPackagesSelectionView.swift @@ -23,45 +23,54 @@ struct WooCarrierPackagesView: View { @Binding var starredPackages: Set let tapAction: (String) -> Void let starAction: (String) -> Void + let onRefresh: () async -> Void var body: some View { - List { - ForEach(carrierTab.packageGroups, id: \.id) { packageGroup in - Section { - ForEach(packageGroup.packages, id: \.id) { package in - PackageOptionView( - isSelected: selectedPackageId == package.id, - package: package, - showTopDivider: false, - showSource: false, - tapAction: { - tapAction(package.id) - }, - starAction: { - starAction(package.id) - // Just temporary, will be replaced with proper logic - }, - starred: starredPackages.contains(package.id) - ) - .alignmentGuide(.listRowSeparatorLeading) { _ in - return 16 + ScrollViewReader { scroll in + List { + ForEach(carrierTab.packageGroups, id: \.id) { packageGroup in + Section { + ForEach(packageGroup.packages, id: \.id) { package in + WooShippingPackageOptionView( + isSelected: selectedPackageId == package.id, + package: package, + showTopDivider: false, + showSource: false, + tapAction: { + tapAction(package.id) + }, + starAction: { + starAction(package.id) + }, + starred: starredPackages.contains(package.id) + ) + .alignmentGuide(.listRowSeparatorLeading) { _ in + return 16 + } + .id(package.id) } + } header: { + HStack { + Text(packageGroup.name.uppercased()) + .foregroundColor(.secondary) + .captionStyle() + .multilineTextAlignment(.leading) + Spacer() + } + .padding(.horizontal) + .background(Color.clear) } - } header: { - HStack { - Text(packageGroup.name.uppercased()) - .foregroundColor(.secondary) - .captionStyle() - .multilineTextAlignment(.leading) - Spacer() - } - .padding(.horizontal) - .background(Color.clear) + .listRowInsets(.zero) } - .listRowInsets(.zero) + } + .task { + scroll.scrollTo(selectedPackageId) } } .listStyle(.plain) + .refreshable { + await onRefresh() + } } } @@ -84,12 +93,6 @@ struct WooCarrierPackagesSelectionView: View { var body: some View { VStack(spacing: 0) { - if viewModel.isLoadingPackages { - // TODO: think of a better progress/loading indicator - ProgressView() - .progressViewStyle(.circular) - .padding() - } if viewModel.selectedCarriersTabIndex != nil, viewModel.carrierTabs.isNotEmpty { TopTabView(tabs: viewModel.carrierTabs, showContent: .constant(false), @@ -103,6 +106,22 @@ struct WooCarrierPackagesSelectionView: View { tabItemContentHorizontalPadding: Constants.tabItemContentHorizontalPadding, tabItemContentVerticalPadding: Constants.tabItemContentVerticalPadding) } + // Show extra loading indicator in case there are no packages + else if viewModel.isLoadingPackages { + ProgressView() + .progressViewStyle(.circular) + .padding() + } + else { + Button { + Task { + await viewModel.loadPackages() + } + } label: { + Image(systemName: "arrow.trianglehead.counterclockwise") + } + .padding() + } if let selectedCarrierTab = viewModel.selectedCarrierTab { WooCarrierPackagesView(carrierTab: selectedCarrierTab, selectedPackageId: $viewModel.selectedCarriersPackageId, @@ -110,22 +129,44 @@ struct WooCarrierPackagesSelectionView: View { tapAction: { packageID in viewModel.selectedCarriersPackageId = viewModel.selectedCarriersPackageId == packageID ? nil : packageID }, starAction: { packageID in - Task { - await viewModel.starUnstarPackage(packageID) - } + viewModel.starUnstarPackage(packageID, carrierID: selectedCarrierTab.carrier.rawValue) + }, onRefresh: { + await viewModel.loadPackages() }) } Spacer() Divider() - Button(WooShippingAddPackageView.Localization.addPackage) { + Button(selectionButtonText) { addPackageButtonTapped() } - .disabled(viewModel.selectedCarriersPackageId == nil) - .buttonStyle(PrimaryButtonStyle()) + .disabled(selectionButtonDisabled) + .if(viewModel.previousSelectedAndSelectedCarriersPackageAreSame) { + $0.buttonStyle(SecondaryButtonStyle()) + } + .if(!viewModel.previousSelectedAndSelectedCarriersPackageAreSame) { + $0.buttonStyle(PrimaryButtonStyle()) + } .padding() } } + private var selectionButtonDisabled: Bool { + viewModel.selectedCarriersPackageId == nil + } + + private var selectionButtonText: String { + if selectionButtonDisabled { + return WooShippingAddPackageView.Localization.selectPackage + } + if let previousSelectedPackage = viewModel.previousSelectedPackage { + if previousSelectedPackage.id == viewModel.selectedCarriersPackageId { + return WooShippingAddPackageView.Localization.done + } + return WooShippingAddPackageView.Localization.useSelectedPackage + } + return WooShippingAddPackageView.Localization.addPackage + } + private func addPackageButtonTapped() { // call addPackageAction with data from selected package guard let selectedPackage = viewModel.selectedCarriersPackage else { return } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooSavedPackagesSelectionView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooSavedPackagesSelectionView.swift index 7c001491316..6d049cb5daf 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooSavedPackagesSelectionView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooSavedPackagesSelectionView.swift @@ -2,16 +2,23 @@ import SwiftUI enum WooShippingPackageSource { case custom - case predefined(String) + case predefined(sourceTitle: String, sourceID: String) var userFriendlyDescription: String { switch self { case .custom: return NSLocalizedString("Custom Package", comment: "Label used to mark a custom package in list of saved packages") - case .predefined(let source): - return source + case .predefined(let sourceTitle, _): + return sourceTitle } } + + var sourceID: String? { + guard case .predefined(_, let sourceID) = self else { + return nil + } + return sourceID + } } protocol WooShippingPackageDataRepresentable { @@ -23,8 +30,6 @@ protocol WooShippingPackageDataRepresentable { var height: String { get } var weight: String { get } // local - var weightDescription: String { get } - var dimensionsDescription: String { get } var source: WooShippingPackageSource { get } // custom, predefined var packageType: String { get } // box, envelope } @@ -38,8 +43,6 @@ struct WooShippingPackageData: WooShippingPackageDataRepresentable { let height: String let weight: String // local - let weightDescription: String - let dimensionsDescription: String let source: WooShippingPackageSource let packageType: String @@ -48,9 +51,7 @@ struct WooShippingPackageData: WooShippingPackageDataRepresentable { length: String, width: String, height: String, - dimensionsUnit: String, weight: String, - weightUnit: String, source: WooShippingPackageSource, packageType: String) { self.id = id @@ -62,18 +63,13 @@ struct WooShippingPackageData: WooShippingPackageDataRepresentable { self.source = source self.packageType = packageType - - self.dimensionsDescription = WooShippingPackageData.createDimensionsDescription(length: length, width: width, height: height, unit: dimensionsUnit) - self.weightDescription = WooShippingPackageData.createWeightsDescription(weight: weight, unit: weightUnit) } init(name: String, length: String, width: String, height: String, - dimensionsUnit: String, weight: String, - weightUnit: String, source: WooShippingPackageSource, packageType: String) { self.init(id: name, @@ -81,22 +77,27 @@ struct WooShippingPackageData: WooShippingPackageDataRepresentable { length: length, width: width, height: height, - dimensionsUnit: dimensionsUnit, weight: weight, - weightUnit: weightUnit, source: source, packageType: packageType) } } extension WooShippingPackageDataRepresentable { - static func createDimensionsDescription(length: String, width: String, height: String, unit: String) -> String { + func dimensionsDescription(unit: String) -> String { return "\(length) x \(width) x \(height) \( unit)" } - static func createWeightsDescription(weight: String, unit: String) -> String { + func weightDescription(unit: String) -> String? { + guard weight.isNotEmpty else { + return nil + } return "\(weight) \(unit)" } + + var displayName: String { + name.isNotEmpty ? name : source.userFriendlyDescription + } } struct WooSavedPackagesSelectionView: View { @@ -110,30 +111,73 @@ struct WooSavedPackagesSelectionView: View { var body: some View { VStack(spacing: 0) { - Divider() - if viewModel.isLoadingPackages { - ProgressView() - .progressViewStyle(.circular) + if !viewModel.hasSavedPackages { + // Show extra loading indicator in case there are no packages + if viewModel.isLoadingPackages { + ProgressView() + .progressViewStyle(.circular) + .padding() + } + else { + Button { + Task { + await viewModel.loadPackages() + } + } label: { + Image(systemName: "arrow.trianglehead.counterclockwise") + } .padding() + } } - List { - packagesSection(for: viewModel.customSavedPackages) - packagesSection(for: viewModel.predefinedSavedPackages) - } - .listStyle(.plain) - .refreshable { - viewModel.loadPackages() + else { + Divider() + ScrollViewReader { scroll in + List { + packagesSection(for: viewModel.customSavedPackages) + packagesSection(for: viewModel.predefinedSavedPackages) + } + .listStyle(.plain) + .refreshable { + await viewModel.loadPackages() + } + .task { + scroll.scrollTo(viewModel.selectedSavedPackageId) + } + } + Divider() } - Divider() - Button(WooShippingAddPackageView.Localization.addPackage) { + Spacer() + Button(selectionButtonText) { addPackageButtonTapped() } - .disabled(viewModel.selectedSavedPackageId == nil || !viewModel.hasSavedPackages) - .buttonStyle(PrimaryButtonStyle()) + .disabled(selectionButtonDisabled) + .if(viewModel.previousSelectedAndSelectedSavedPackageAreSame) { + $0.buttonStyle(SecondaryButtonStyle()) + } + .if(!viewModel.previousSelectedAndSelectedSavedPackageAreSame) { + $0.buttonStyle(PrimaryButtonStyle()) + } .padding() } } + private var selectionButtonDisabled: Bool { + viewModel.selectedSavedPackageId == nil || !viewModel.hasSavedPackages + } + + private var selectionButtonText: String { + if selectionButtonDisabled { + return WooShippingAddPackageView.Localization.selectPackage + } + if let previousSelectedPackage = viewModel.previousSelectedPackage { + if previousSelectedPackage.id == viewModel.selectedSavedPackageId { + return WooShippingAddPackageView.Localization.done + } + return WooShippingAddPackageView.Localization.useSelectedPackage + } + return WooShippingAddPackageView.Localization.addPackage + } + @ViewBuilder private func packagesSection(for packages: [any WooShippingPackageDataRepresentable]) -> some View { if packages.isEmpty { @@ -149,7 +193,7 @@ struct WooSavedPackagesSelectionView: View { private func packagesRows(for packages: [any WooShippingPackageDataRepresentable]) -> some View { ForEach(packages, id: \.id) { package in - PackageOptionView( + WooShippingPackageOptionView( isSelected: viewModel.selectedSavedPackageId == package.id, package: package, showTopDivider: false, @@ -158,16 +202,14 @@ struct WooSavedPackagesSelectionView: View { viewModel.selectedSavedPackageId = viewModel.selectedSavedPackageId == package.id ? nil : package.id } ) + .id(package.id) .alignmentGuide(.listRowSeparatorLeading) { _ in return 16 } .swipeActions { Button { withAnimation { - _ = Task { - return await viewModel.removeSavedPackage(package) - } - // TODO: handle error + viewModel.removeSavedPackage(package) } } label: { Image(systemName: "trash") diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddCustomPackageViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddCustomPackageViewModel.swift index 91059510835..cb218c0c581 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddCustomPackageViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddCustomPackageViewModel.swift @@ -8,27 +8,31 @@ final class WooShippingAddCustomPackageViewModel: ObservableObject { // Holds values for all dimension input fields. // Using a dictionary so we can easily add/remove new types // if needed just by adding new case in enum - @Published var fieldValues: [WooShippingPackageUnitType: String] = [:] + @Published var fieldValues: [WooShippingPackageUnitType: String] // Holds selected package type when custom package is selected, it can be `box` or `envelope` - @Published var packageType: WooShippingPackageType = .box + @Published var packageType: WooShippingPackageType // Holds value for toggle that determines if we are showing button for saving the template @Published var showSaveTemplate: Bool = false @Published var packageTemplateName: String = "" - // The dimension unit used in the store (e.g. "in") - let dimensionsUnit: String - // The weight unit used in the store (e.g. "kg") - let weightUnit: String // MARK: Initialization - init(siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, - dimensionsUnit: String, - weightUnit: String, + init(selectedPackage: WooShippingPackageDataRepresentable? = nil, + siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, stores: StoresManager = ServiceLocator.stores) { self.stores = stores self.siteID = siteID - self.dimensionsUnit = dimensionsUnit - self.weightUnit = weightUnit + if let selectedPackage { + fieldValues = [ + .length: selectedPackage.length, + .width: selectedPackage.width, + .height: selectedPackage.height + ] + packageType = WooShippingPackageType(rawValue: selectedPackage.packageType) ?? .box + } else { + fieldValues = [:] + packageType = .box + } } // Field values are invalid if one of them is empty @@ -55,9 +59,7 @@ final class WooShippingAddCustomPackageViewModel: ObservableObject { length: fieldValues[.length] ?? "", width: fieldValues[.width] ?? "", height: fieldValues[.height] ?? "", - dimensionsUnit: dimensionsUnit, weight: fieldValues[.weight] ?? "", - weightUnit: weightUnit, source: .custom, packageType: packageType.rawValue) } @@ -113,9 +115,7 @@ final class WooShippingAddCustomPackageViewModel: ObservableObject { length: savedPackage.getLength().description, width: savedPackage.getWidth().description, height: savedPackage.getHeight().description, - dimensionsUnit: dimensionsUnit, weight: savedPackage.boxWeight.description, - weightUnit: weightUnit, source: .custom, packageType: savedPackage.rawType) continuation.resume(returning: .success(packageData)) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageView.swift index a6f0f33f178..998cda1b4c5 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageView.swift @@ -1,6 +1,5 @@ import SwiftUI import Combine -import struct Yosemite.ShippingLabelStoreOptions struct WooShippingAddPackageView: View { enum PackageProviderType: CaseIterable { @@ -18,27 +17,26 @@ struct WooShippingAddPackageView: View { } @Environment(\.presentationMode) var presentationMode - - // Holds type of selected package, it can be `custom`, `carrier` or `saved` - @State var selectedPackageType = PackageProviderType.custom - @StateObject var packagesViewModel = WooShippingAddPackageViewModel() - @State var customPackageViewModel: WooShippingAddCustomPackageViewModel? - @ObservedObject var createLabelsViewModel: WooShippingCreateLabelsViewModel + @ObservedObject var packagesViewModel: WooShippingAddPackageViewModel + @ObservedObject var customPackageViewModel: WooShippingAddCustomPackageViewModel let addPackageAction: (WooShippingPackageDataRepresentable) -> Void @State private var cancellable: AnyCancellable? - init(createLabelsViewModel: WooShippingCreateLabelsViewModel, + @Environment(\.shippingWeightUnit) private var weightUnit + @Environment(\.shippingDimensionsUnit) private var dimensionsUnit + + init(selectedPackage: WooShippingPackageDataRepresentable? = nil, addPackageAction: @escaping (WooShippingPackageDataRepresentable) -> Void) { - self.createLabelsViewModel = createLabelsViewModel self.addPackageAction = addPackageAction - } - - private func loadCustomPackageViewModelWithStoreOptions(_ storeOptions: ShippingLabelStoreOptions?) { - guard let storeOptions, customPackageViewModel == nil else { return } - customPackageViewModel = WooShippingAddCustomPackageViewModel(dimensionsUnit: storeOptions.dimensionUnit, - weightUnit: storeOptions.weightUnit) + packagesViewModel = WooShippingAddPackageViewModel(selectedPackage: selectedPackage) + switch selectedPackage?.source { + case .custom: + customPackageViewModel = WooShippingAddCustomPackageViewModel(selectedPackage: selectedPackage) + default: + customPackageViewModel = WooShippingAddCustomPackageViewModel() + } } // MARK: - UI @@ -46,7 +44,7 @@ struct WooShippingAddPackageView: View { var body: some View { NavigationView { VStack { - Picker("", selection: $selectedPackageType) { + Picker("", selection: $packagesViewModel.selectedPackageType) { ForEach(PackageProviderType.allCases, id: \.self) { Text($0.name) } @@ -64,85 +62,26 @@ struct WooShippingAddPackageView: View { }) } } - .navigationTitle(Localization.addPackage) + .navigationTitle(packagesViewModel.previousSelectedPackage != nil ? Localization.editPackage : Localization.addPackage) .navigationBarTitleDisplayMode(.inline) } .navigationViewStyle(.stack) - .task { - packagesViewModel.loadPackages() - } - .onAppear() { - if let storeOptions = createLabelsViewModel.storeOptions { - loadCustomPackageViewModelWithStoreOptions(storeOptions) - } - else { - cancellable = createLabelsViewModel.$storeOptions - .receive(on: DispatchQueue.main) - .sink { storeOptions in - loadCustomPackageViewModelWithStoreOptions(storeOptions) - } - } - } } // MARK: UI components @ViewBuilder private var selectedPackageTypeView: some View { - switch selectedPackageType { + switch packagesViewModel.selectedPackageType { case .custom: - customPackageView + WooAddCustomPackageView(viewModel: customPackageViewModel, + addPackageAction: addPackageAction) case .carrier: - carrierPackageView + WooCarrierPackagesSelectionView(viewModel: packagesViewModel, + addPackageAction: addPackageAction) case .saved: - savedPackageView - } - } - - @ViewBuilder - private var customPackageView: some View { - if let customPackageViewModel { - WooAddCustomPackageView(viewModel: customPackageViewModel) { packageData in - addPackageAction(packageData) - } - } - else { - storeOptionsLoadingView - } - } - - private var storeOptionsLoadingView: some View { - VStack { - HStack { - Spacer() - if createLabelsViewModel.isLoadingStoreOptions { - ActivityIndicator(isAnimating: .constant(true), style: .large) - } - else { - Button { - createLabelsViewModel.loadStoreOptions() - } label: { - Image(systemName: "arrow.trianglehead.counterclockwise") - } - } - Spacer() - } - Spacer() - } - .padding() - } - - @ViewBuilder - private var carrierPackageView: some View { - WooCarrierPackagesSelectionView(viewModel: packagesViewModel) { packageData in - addPackageAction(packageData) - } - } - - @ViewBuilder - private var savedPackageView: some View { - WooSavedPackagesSelectionView(viewModel: packagesViewModel) { packageData in - addPackageAction(packageData) + WooSavedPackagesSelectionView(viewModel: packagesViewModel, + addPackageAction: addPackageAction) } } } @@ -199,5 +138,20 @@ extension WooShippingAddPackageView { static let saved = NSLocalizedString("wooShipping.createLabel.addPackage.saved", value: "Saved", comment: "Info label for saved package option") + static let selectPackage = NSLocalizedString("wooShipping.createLabel.addPackage.selectPackage", + value: "Select Package", + comment: "Title for the Add Package screen Select Package button") + static let addPackageDetails = NSLocalizedString("wooShipping.createLabel.addPackage.addPackageDetails", + value: "Add Package Details", + comment: "Title for the Add Package screen Add Package Details button") + static let editPackage = NSLocalizedString("wooShipping.createLabel.editPackage.title", + value: "Edit Package", + comment: "Title for the Edit Package screen") + static let done = NSLocalizedString("wooShipping.createLabel.editPackage.done", + value: "Done", + comment: "Title for the Edit Package screen Done button") + static let useSelectedPackage = NSLocalizedString("wooShipping.createLabel.editPackage.useSelectedPackage", + value: "Use Selected Package", + comment: "Title for the Edit Package screen Use Selected Package button") } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageViewModel.swift index 08a06b404f6..9d0b2913ac9 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingAddPackageViewModel.swift @@ -2,21 +2,46 @@ import Foundation import SwiftUI import Combine import Yosemite +import protocol Storage.StorageManagerType final class WooShippingAddPackageViewModel: ObservableObject { private let siteID: Int64 private let stores: StoresManager + private let storage: StorageManagerType private let starAnimation: Animation = .spring(duration: 0.2) - init(siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, - stores: StoresManager = ServiceLocator.stores) { + // Holds type of selected package, it can be `custom`, `carrier` or `saved` + @Published var selectedPackageType: WooShippingAddPackageView.PackageProviderType + + init(selectedPackage: WooShippingPackageDataRepresentable? = nil, + siteID: Int64 = ServiceLocator.stores.sessionManager.defaultStoreID ?? 0, + stores: StoresManager = ServiceLocator.stores, + storage: StorageManagerType = ServiceLocator.storageManager) { self.siteID = siteID self.stores = stores + self.storage = storage + selectedPackageType = .custom + previousSelectedPackage = selectedPackage + // Optimistically set the selected package ID. + // We will select the correct package type (custom, carrier or saved) after loading the packages. + switch selectedPackage?.source { + case .custom: + selectedSavedPackageId = selectedPackage?.id + case .predefined: + selectedCarriersPackageId = selectedPackage?.id + selectedSavedPackageId = selectedPackage?.id + case nil: + break + } + configureResultsController() } @Published private(set) var isLoadingPackages: Bool = false + /// Holds the previously selected package data, which can be transformed e.g. to select the correct tabs in the view. + let previousSelectedPackage: WooShippingPackageDataRepresentable? + // MARK: - saved @Published var selectedSavedPackageId: String? = nil // Track the selected package index @@ -40,6 +65,13 @@ final class WooShippingAddPackageViewModel: ObservableObject { return nil } + var previousSelectedAndSelectedSavedPackageAreSame: Bool { + guard let previousSelectedPackage else { + return false + } + return previousSelectedPackage.id == selectedSavedPackageId + } + // MARK: - carrier @Published private(set) var carrierPackages: [WooShippingCarrierPackages] = [] @@ -47,6 +79,7 @@ final class WooShippingAddPackageViewModel: ObservableObject { @Published var selectedCarriersPackageId: String? = nil @Published var starredCarriersPackages: Set = [] @Published private(set) var carrierTabs: [TopTabItem] = [] + private var allPredefinedOptions: [WooShippingCarrierPredefinedOptions] = [] var selectedCarrierTab: WooShippingCarrierPackages? { guard let selectedCarriersTabIndex else { return nil } @@ -67,37 +100,89 @@ final class WooShippingAddPackageViewModel: ObservableObject { return nil } + var previousSelectedAndSelectedCarriersPackageAreSame: Bool { + guard let previousSelectedPackage else { + return false + } + return previousSelectedPackage.id == selectedCarriersPackageId + } + + // MARK: - Storage + + /// Packages + /// + private lazy var packagesResultsController: ResultsController = { + let predicate = NSPredicate(format: "siteID == %lld", siteID) + return ResultsController(storageManager: storage, matching: predicate, sortedBy: []) + }() + + func configureResultsController() { + packagesResultsController.onDidChangeContent = transformLoadedPackages + packagesResultsController.onDidResetContent = transformLoadedPackages + + do { + try packagesResultsController.performFetch() + transformLoadedPackages() + } catch { + ServiceLocator.crashLogging.logError(error) + } + } // MARK: - loading - func loadPackages() { - guard !isLoadingPackages else { return } + @MainActor + @discardableResult + func loadPackages() async -> Result { + guard !isLoadingPackages else { + return .failure(WooShippingLoadPackagesError.loadingInProgress) + } isLoadingPackages = true - let loadPackagesAction = WooShippingAction.loadPackages(siteID: siteID) { result in - switch result { - case .success(let packagesResult): - self.transformLoadedPackages(packagesResult) - case .failure: - break + let result: Result = await withCheckedContinuation { continuation in + let loadPackagesAction = WooShippingAction.loadPackages(siteID: siteID) { result in + continuation.resume(returning: result) } - self.isLoadingPackages = false + stores.dispatch(loadPackagesAction) } - ServiceLocator.stores.dispatch(loadPackagesAction) + if case .failure(let error) = result { + DDLogError("⛔️ Error loading packages for Woo Shipping labels: \(error)") + } + + isLoadingPackages = false + + return result } // transform packages - private func transformLoadedPackages(_ packagesResult: WooShippingPackagesResponse) { - let customSavedPackages = packagesResult.customPackages.map { - return $0.toPackageData(storeOptions: packagesResult.storeOptions) + private func transformLoadedPackages() { + guard let packages = packagesResultsController.fetchedObjects.first else { + return } - let predefinedSavedPackages = packagesResult.savedPredefinedPackages.map { - return $0.toPackageData(storeOptions: packagesResult.storeOptions) + let customSavedPackages = packages.customPackages.map { + return $0.toPackageData() + }.sorted { $0.id < $1.id } + let predefinedSavedPackages = packages.savedPredefinedPackages.map { + return $0.toPackageData() + }.sorted { $0.id < $1.id } + var carrierPackages: [WooShippingCarrierPackages] = packages.allPredefinedOptions.compactMap { + return $0.toCarrierPackages() } - let carrierPackages: [WooShippingCarrierPackages] = packagesResult.allPredefinedOptions.compactMap { - return $0.toCarrierPackages(storeOptions: packagesResult.storeOptions) + if self.carrierPackages.isNotEmpty { + // sort new packages so they stay in similar order + // sort only if we already had carrier packages before + let sortedCarrierPackages = self.carrierPackages.sorted { (carrierA, carrierB) in + let carrierAIndex = self.carrierPackages.firstIndex(where: { $0.id == carrierA.id }) + let carrierBIndex = self.carrierPackages.firstIndex(where: { $0.id == carrierB.id }) + if let firstI = carrierAIndex, let secondI = carrierBIndex { + return firstI < secondI + } + else { + return carrierAIndex != nil + } + } + carrierPackages = sortedCarrierPackages } let carrierTabs: [TopTabItem] = carrierPackages.map { carrierTab in return TopTabItem(name: carrierTab.carrier.name, icon: carrierTab.carrier.logo, content: { @@ -109,94 +194,152 @@ final class WooShippingAddPackageViewModel: ObservableObject { self.predefinedSavedPackages = predefinedSavedPackages self.carrierPackages = carrierPackages self.carrierTabs = carrierTabs + + self.allPredefinedOptions = packages.allPredefinedOptions + + starredCarriersPackages = Set(predefinedSavedPackages.map { $0.id }) + + // Select package type matching the previous selected package, if it is the currently selected package + if let previousSelectedPackage, previousSelectedPackage.id == selectedSavedPackageId || previousSelectedPackage.id == selectedCarriersPackageId { + switch previousSelectedPackage.source { + case .predefined: + selectedPackageType = predefinedSavedPackages.contains(where: { $0.id == previousSelectedPackage.id }) ? .saved : .carrier + case .custom: + selectedPackageType = customSavedPackages.contains(where: { $0.id == previousSelectedPackage.id }) ? .saved : .custom + } + } + if selectedCarriersTabIndex == nil { - self.selectedCarriersTabIndex = carrierPackages.isEmpty ? nil : 0 + // Select the carriers tab matching the previous selected carriers package, if it is the currently selected package + if let previousSelectedPackage, selectedCarriersPackageId == previousSelectedPackage.id { + selectedCarriersTabIndex = carrierPackages.firstIndex { carrierTab in + return carrierTab.carrier.rawValue == previousSelectedPackage.source.sourceID + } + } else { + selectedCarriersTabIndex = carrierPackages.isEmpty ? nil : 0 + } } } // star/unstar packages - func starUnstarPackage(_ packageID: String) async -> Error? { + @MainActor + func starUnstarPackage(_ packageID: String, carrierID: String) { if starredCarriersPackages.contains(packageID) { _ = withAnimation(starAnimation) { starredCarriersPackages.remove(packageID) } + // TODO: use delete action when it is ready (https://github.com/woocommerce/woocommerce-ios/issues/14679) } else { _ = withAnimation(starAnimation) { starredCarriersPackages.insert(packageID) } + + let predefined = WooShippingPredefinedSavedOption(id: carrierID, predefinedPackageIDs: [packageID]) + let createAction = WooShippingAction.createPackage(siteID: siteID, customPackage: nil, predefinedOption: predefined) { [weak self] result in + if case .failure(let error) = result { + DDLogError("⛔️ Error saving Woo Shipping package: \(error)") + self?.starredCarriersPackages.remove(packageID) + } + } + stores.dispatch(createAction) } - return nil } // delete saved packages - func removeSavedPackage(_ packageToRemove: WooShippingPackageDataRepresentable) async -> Error? { - // TODO: rewrite to directly use actions + @MainActor + func removeSavedPackage(_ packageToRemove: WooShippingPackageDataRepresentable) { // delete the package locally and on backend - customSavedPackages.removeAll { package in package.id == packageToRemove.id } - predefinedSavedPackages.removeAll { package in package.id == packageToRemove.id } + + // delete locally + let customPackagesIndex = customSavedPackages.firstIndex(where: { $0.id == packageToRemove.id }) + let predefinedPackagesIndex = predefinedSavedPackages.firstIndex(where: { $0.id == packageToRemove.id }) + + if let customPackagesIndex { + customSavedPackages.remove(at: customPackagesIndex) + } + if let predefinedPackagesIndex { + predefinedSavedPackages.remove(at: predefinedPackagesIndex) + } + + let removedStarredCarrierID = starredCarriersPackages.remove(packageToRemove.id) if self.selectedSavedPackageId == packageToRemove.id { self.selectedSavedPackageId = nil } - return nil + // delete on backend + let deleteAction = WooShippingAction.deletePackage(siteID: siteID, packageID: packageToRemove.id) { result in + if case .failure(let error) = result { + DDLogError("⛔️ Error removing saved Woo Shipping package: \(error)") + + // undo removing of the package + // first: undo starring + if let carrierID = removedStarredCarrierID { + self.starredCarriersPackages.insert(carrierID) + } + // second: undo removing from custom saved + if let customPackagesIndex { + self.customSavedPackages.insert(packageToRemove, at: customPackagesIndex) + } + // third: undo removing from predefined saved + if let predefinedPackagesIndex { + self.predefinedSavedPackages.insert(packageToRemove, at: predefinedPackagesIndex) + } + } + } + + stores.dispatch(deleteAction) } } extension WooShippingCustomPackage { - func toPackageData(storeOptions: ShippingLabelStoreOptions) -> WooShippingPackageData { + func toPackageData() -> WooShippingPackageData { return WooShippingPackageData(id: id, name: name, length: String(getLength()), width: String(getWidth()), height: String(getHeight()), - dimensionsUnit: storeOptions.dimensionUnit, weight: String(boxWeight), - weightUnit: storeOptions.weightUnit, source: .custom, packageType: rawType) } } extension WooShippingPredefinedPackage { - func toPackageData(storeOptions: ShippingLabelStoreOptions, groupTitle: String) -> WooShippingPackageData { + func toPackageData(groupTitle: String, sourceID: String) -> WooShippingPackageData { return WooShippingPackageData(id: id, name: name, length: String(getLength()), width: String(getWidth()), height: String(getHeight()), - dimensionsUnit: storeOptions.dimensionUnit, weight: String(boxWeight), - weightUnit: storeOptions.weightUnit, - source: .predefined(groupTitle), + source: .predefined(sourceTitle: groupTitle, sourceID: sourceID), packageType: isLetter ? "envelope" : "box") } } extension WooShippingSavedPredefinedPackage { - func toPackageData(storeOptions: ShippingLabelStoreOptions) -> WooShippingPackageData { + func toPackageData() -> WooShippingPackageData { return WooShippingPackageData(id: id, name: self.package.name, length: String(self.package.getLength()), width: String(self.package.getWidth()), height: String(self.package.getHeight()), - dimensionsUnit: storeOptions.dimensionUnit, weight: self.package.boxWeight, - weightUnit: storeOptions.weightUnit, - source: .predefined(groupTitle), + source: .predefined(sourceTitle: groupTitle, sourceID: providerID), packageType: self.package.isLetter ? "envelope" : "box") } } extension WooShippingCarrierPredefinedOptions { - func toCarrierPackages(storeOptions: ShippingLabelStoreOptions) -> WooShippingCarrierPackages? { + func toCarrierPackages() -> WooShippingCarrierPackages? { guard let shippingCarrier = WooShippingCarrier(rawValue: carrierID) else { return nil } let packageGroups = predefinedOptions.compactMap { predefinedOption in let packages = predefinedOption.predefinedPackages.map { package in - return package.toPackageData(storeOptions: storeOptions, groupTitle: predefinedOption.title) - } + return package.toPackageData(groupTitle: predefinedOption.title, sourceID: predefinedOption.providerID) + }.sorted { $0.id < $1.id } let group = WooPackageGroup(name: predefinedOption.title, packages: packages) return group } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageAndRatePlaceholder.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageAndRatePlaceholder.swift index bef4ea661cc..7c03abb2e98 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageAndRatePlaceholder.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageAndRatePlaceholder.swift @@ -5,7 +5,6 @@ struct WooShippingPackageAndRatePlaceholder: View { let onSelectPackage: (WooShippingPackageDataRepresentable) -> Void @State private var showAddPackage: Bool = false - @ObservedObject var viewModel: WooShippingCreateLabelsViewModel var body: some View { VStack(spacing: .zero) { @@ -29,7 +28,7 @@ struct WooShippingPackageAndRatePlaceholder: View { .padding(Layout.padding) .roundedBorder(cornerRadius: Layout.borderCornerRadius, lineColor: Color(.border), lineWidth: Layout.borderLineWidth, dashed: true) .sheet(isPresented: $showAddPackage) { - WooShippingAddPackageView(createLabelsViewModel: viewModel) { packageData in + WooShippingAddPackageView() { packageData in onSelectPackage(packageData) showAddPackage = false } @@ -67,8 +66,7 @@ private extension WooShippingPackageAndRatePlaceholder { import struct Yosemite.Order #Preview { - WooShippingPackageAndRatePlaceholder(onSelectPackage: { _ in }, - viewModel: WooShippingCreateLabelsViewModel(order: Order.sampleOrder)) + WooShippingPackageAndRatePlaceholder(onSelectPackage: { _ in }) .padding() } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/PackageOptionView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageOptionView.swift similarity index 80% rename from WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/PackageOptionView.swift rename to WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageOptionView.swift index 431ebaf91fc..934925aaff0 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/PackageOptionView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingPackageOptionView.swift @@ -1,6 +1,6 @@ import SwiftUI -struct PackageOptionView: View { +struct WooShippingPackageOptionView: View { enum Constants { static let verticalSpacing: CGFloat = 4.0 static let textContentLeadingPadding: CGFloat = 4.0 @@ -15,6 +15,9 @@ struct PackageOptionView: View { var starAction: (() -> Void)? var starred: Bool? + @Environment(\.shippingDimensionsUnit) private var dimensionsUnit + @Environment(\.shippingWeightUnit) private var weightUnit + var body: some View { HStack(spacing: 0) { HStack { @@ -29,12 +32,14 @@ struct PackageOptionView: View { .font(.caption) .foregroundStyle(Color(.secondaryLabel)) } - Text(package.name) + Text(package.displayName) .bodyStyle() HStack { - Text(package.dimensionsDescription) - Text("•") - Text(package.weightDescription) + Text(package.dimensionsDescription(unit: dimensionsUnit)) + if let weight = package.weightDescription(unit: weightUnit) { + Text("•") + Text(weight) + } } .font(.subheadline) .foregroundStyle(Color(.text)) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingSelectedPackageView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingSelectedPackageView.swift new file mode 100644 index 00000000000..1a74168ba16 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingSelectedPackageView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct WooShippingSelectedPackageView: View { + let package: WooShippingPackageDataRepresentable + @Binding var totalWeight: String + + @Environment(\.shippingWeightUnit) private var weightUnit + + @State private var showPackageSelection = false + + /// Closure to perform when a new package is selected. + let updateSelectedPackage: (WooShippingPackageDataRepresentable) -> Void + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(Localization.package) + .headlineStyle() + Spacer() + PencilEditButton { + showPackageSelection = true + } + .buttonStyle(TextButtonStyle()) + } + WooShippingPackageOptionView(package: package, + showTopDivider: false, + showSource: true, + tapAction: {}) + .roundedBorder(cornerRadius: Constants.cornerRadius, lineColor: Constants.lineColor, lineWidth: Constants.lineWidth) + .padding(.bottom) + shipmentWeight + } + .sheet(isPresented: $showPackageSelection) { + WooShippingAddPackageView(selectedPackage: package) { newPackage in + updateSelectedPackage(newPackage) + showPackageSelection = false + } + } + } + + @FocusState var isTotalWeightInputActive: Bool + + private var shipmentWeight: some View { + VStack(alignment: .leading) { + Text(Localization.totalWeight) + .bodyStyle() + HStack { + TextField("", text: $totalWeight) + .keyboardType(.decimalPad) + .bodyStyle() + .focused($isTotalWeightInputActive) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button { + isTotalWeightInputActive = false + } label: { + Text(Localization.done) + .bold() + } + } + } + Text(weightUnit) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding() + .roundedBorder(cornerRadius: Constants.cornerRadius, lineColor: Constants.lineColor, lineWidth: Constants.lineWidth) + } + } +} + +private extension WooShippingSelectedPackageView { + enum Constants { + static let cornerRadius: CGFloat = 8 + static let lineColor = Color(.separator) + static let lineWidth: CGFloat = 0.5 + } + + enum Localization { + static let package = NSLocalizedString("wooShipping.createLabels.package.title", + value: "Package", + comment: "Heading for the package section in the shipping label creation screen.") + static let totalWeight = NSLocalizedString("wooShipping.createLabels.package.totalWeight", + value: "Total shipment weight (with package)", + comment: "Label for the total shipment weight input field in the shipping label creation screen.") + static let done = NSLocalizedString("wooShipping.createLabels.package.done", + value: "Done", + comment: "Button for dismissing the keyboard") + } +} + +#Preview("Carrier package") { + WooShippingSelectedPackageView(package: WooShippingPackageData(name: "Small Flat Rate Box", + length: "12", + width: "6", + height: "6", + weight: "4", + source: .predefined(sourceTitle: "USPS Priority Mail Flat Rate Boxes", sourceID: "usps"), + packageType: "box"), + totalWeight: .constant("6"), + updateSelectedPackage: { _ in }) + .shippingDimensionsUnit("in") + .shippingWeightUnit("lb") +} + +#Preview("Unsaved custom package") { + WooShippingSelectedPackageView(package: WooShippingPackageData(name: "", + length: "12", + width: "6", + height: "6", + weight: "", + source: .custom, + packageType: "box"), + totalWeight: .constant("6"), + updateSelectedPackage: { _ in }) + .shippingDimensionsUnit("in") + .shippingWeightUnit("lb") +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingServiceView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingServiceView.swift index 7a06bee1866..654f4bf2123 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingServiceView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Package and Rate Selection/WooShippingServiceView.swift @@ -50,6 +50,7 @@ struct WooShippingServiceView: View { .redacted(reason: viewModel.loadingState == .loading ? .placeholder : []) .shimmering(active: viewModel.loadingState == .loading) } + .padding(.vertical) } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddress+Woo.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddress+Woo.swift new file mode 100644 index 00000000000..497602fba16 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddress+Woo.swift @@ -0,0 +1,74 @@ +import Foundation +import Contacts +import Yosemite + + +// Yosemite.WooShippingOriginAddress Helper Methods +// +extension WooShippingOriginAddress { + + /// Returns the `name` and `company` (on a new line). If either the `name` or `company` is empty, + /// then a single line is returned containing the other value. + /// + var fullNameWithCompany: String { + var output: [String] = [] + + if let fullName { + output.append(fullName) + } + if company.isNotEmpty { + output.append(company) + } + + return output.joined(separator: "\n") + } + + /// Returns the first and last name combined (if there are, effectively, two names). + /// If only one name is present, that name is returned. + var fullName: String? { + switch (firstName.isNotEmpty, lastName.isNotEmpty) { + case (true, true): + return "\(firstName) \(lastName)" + case (true, false): + return firstName + case (false, true): + return lastName + case (false, false): + return nil + } + } + + /// Returns the Postal Address, formatted and ready for display. + /// + var formattedPostalAddress: String? { + return postalAddress.formatted(as: .mailingAddress)?.replacingOccurrences(of: "\n", with: ", ") + } +} + +private extension WooShippingOriginAddress { + + /// Returns the two Address Lines combined (if there are, effectively, two lines). + /// Per US Post Office standardized rules for address lines. Ref. https://pe.usps.com/text/pub28/28c2_001.htm + /// + var combinedAddress: String { + guard address2.isNotEmpty else { + return address1 + } + + return address1 + " " + address2 + } + + /// Returns a CNPostalAddress with the receiver's properties + /// + var postalAddress: CNPostalAddress { + let address = CNMutablePostalAddress() + address.street = combinedAddress + address.city = city + address.state = state + address.postalCode = postcode + address.country = country + address.isoCountryCode = country + + return address + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListView.swift new file mode 100644 index 00000000000..a5f53414397 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// View to display a list of origin addresses for the Woo Shipping extension. +struct WooShippingOriginAddressListView: View { + @ObservedObject var viewModel: WooShippingOriginAddressListViewModel + + var body: some View { + VStack(alignment: .leading, spacing: Constants.verticalSpacing) { + Text(Localization.shipFrom) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, Constants.verticalSpacing) + List(viewModel.addresses) { address in + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: Constants.verticalSpacing) { + AdaptiveStack(horizontalAlignment: .leading, verticalAlignment: .firstTextBaseline) { + Text(address.fullNameWithCompany) + .bold() + if address.defaultAddress { + Text(Localization.defaultAddress) + .bold() + } + } + if let formattedAddress = address.formattedPostalAddress { + Text(formattedAddress) + } + } + Spacer() + PencilEditButton { + // TODO: Edit origin address + } + .buttonStyle(TextButtonStyle()) + } + .padding() + .if(viewModel.isSelected(address)) { + $0.background(Color(.wooCommercePurple(.shade0)), ignoresSafeAreaEdges: .vertical) + } + .roundedBorder(cornerRadius: Constants.cornerRadius, + lineColor: Color(viewModel.isSelected(address) ? .wooCommercePurple(.shade60) : .separator), + lineWidth: viewModel.isSelected(address) ? 2 : 0.5) + .onTapGesture { + viewModel.select(address) + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: Constants.verticalSpacing, trailing: 0)) + } + .listStyle(.plain) + } + .padding() + } +} + +private extension WooShippingOriginAddressListView { + enum Constants { + static let verticalSpacing: CGFloat = 8 + static let cornerRadius: CGFloat = 8 + } +} + +private extension WooShippingOriginAddressListView { + enum Localization { + static let shipFrom = NSLocalizedString("wooShipping.originAddresses.shipFrom", + value: "Ship From", + comment: "Heading for the list of origin addresses to choose from on the shipping label creation screen") + .localizedUppercase + static let defaultAddress = NSLocalizedString("wooShipping.originAddresses.defaultAddress", + value: "(default)", + comment: "Indicates that the address is the default origin address on the shipping label creation screen") + } +} + +#Preview { + WooShippingOriginAddressListView(viewModel: .init(addresses: WooShippingOriginAddressListView.sampleAddresses(), + selectedAddressID: "1")) +} + +#Preview("Bottom sheet presentation") { + Text("Background view") + .sheet(isPresented: .constant(true)) { + WooShippingOriginAddressListView(viewModel: .init(addresses: WooShippingOriginAddressListView.sampleAddresses(), + selectedAddressID: "1")) + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListViewModel.swift new file mode 100644 index 00000000000..a21a53ddae7 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingAddresses/WooShippingOriginAddressListViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Yosemite + +final class WooShippingOriginAddressListViewModel: ObservableObject { + let addresses: [WooShippingOriginAddress] + @Published private(set) var selectedAddressID: String? + + /// Closure (set externally) called when an address is selected. + var onSelect: ((WooShippingOriginAddress) -> Void)? + + init(addresses: [WooShippingOriginAddress], + selectedAddressID: String? = nil) { + self.addresses = addresses + self.selectedAddressID = selectedAddressID + } + + /// Whether the provided address is selected. + func isSelected(_ address: WooShippingOriginAddress) -> Bool { + selectedAddressID == address.id + } + + /// Selects the provided address to use as the origin address for the shipping label. + func select(_ address: WooShippingOriginAddress) { + guard addresses.contains(address) else { + return + } + selectedAddressID = address.id + onSelect?(address) + } +} + +// MARK: SwiftUI Previews +extension WooShippingOriginAddressListView { + static func sampleAddresses() -> [WooShippingOriginAddress] { + [WooShippingOriginAddress(id: "1", + company: "HEADQUARTERS", + address1: "417 MONTGOMERY ST", + address2: "", + city: "SAN FRANCISCO", + state: "CA", + postcode: "94104-1129", + country: "US", + phone: "", + firstName: "GENERAL", + lastName: "MANAGER", + email: "", + defaultAddress: true, + isVerified: true), + WooShippingOriginAddress(id: "2", + company: "WAREHOUSE", + address1: "15 ALGONKIN ST", + address2: "", + city: "TICONDEROGA", + state: "NY", + postcode: "12883-1487", + country: "US", + phone: "", + firstName: "", + lastName: "", + email: "", + defaultAddress: false, + isVerified: true)] + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsView.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsView.swift index 4e6debc01f9..96a531dc766 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsView.swift @@ -34,6 +34,9 @@ struct WooShippingCreateLabelsView: View { /// Whether the shipment details bottom sheet is expanded. @State private var isShipmentDetailsExpanded = false + /// Whether the origin address list sheet is presented. + @State private var isOriginAddressListPresented = false + var body: some View { NavigationStack { ScrollView { @@ -46,18 +49,20 @@ struct WooShippingCreateLabelsView: View { WooShippingHazmat(enabled: !viewModel.canViewLabel) + WooShippingCustomsRow(informationIsCompleted: viewModel.customsInformationIsCompleted) + .padding(.bottom, 16) + if viewModel.canViewLabel { EmptyView() - } else if viewModel.selectedPackage != nil, + } else if let package = viewModel.selectedPackage, let shippingService = viewModel.shippingService { - // TODO: Display package section - // Package heading and edit button - // Selected package details - // Total shipment weight field + WooShippingSelectedPackageView(package: package, + totalWeight: $viewModel.shipmentWeight, + updateSelectedPackage: viewModel.selectPackage) WooShippingServiceView(viewModel: shippingService) .padding(.horizontal, -16) } else { - WooShippingPackageAndRatePlaceholder(onSelectPackage: viewModel.selectPackage, viewModel: viewModel) + WooShippingPackageAndRatePlaceholder(onSelectPackage: viewModel.selectPackage) } } .padding(16) @@ -66,25 +71,39 @@ struct WooShippingCreateLabelsView: View { ExpandableBottomSheet(onChangeOfExpansion: { isExpanded in isShipmentDetailsExpanded = isExpanded }) { - if isShipmentDetailsExpanded && !viewModel.canViewLabel { - CollapsibleHStack(spacing: Layout.bottomSheetSpacing) { - Toggle(Localization.BottomSheet.markComplete, isOn: $viewModel.markOrderComplete) - .font(.subheadline) - .tint(Color(.primary)) - purchaseButton - } - .padding(.horizontal, Layout.bottomSheetPadding) - } else { - VStack { + VStack { + if !isShipmentDetailsExpanded { Text(Localization.BottomSheet.shipmentDetails) .foregroundStyle(Color(.primary)) .bold() - if viewModel.selectedPackage != nil && !viewModel.canViewLabel { - purchaseButton + } + if !viewModel.canViewLabel { + if isiPhonePortrait { + VStack(spacing: Layout.bottomSheetSpacing) { + if isShipmentDetailsExpanded { + Toggle(Localization.BottomSheet.markComplete, isOn: $viewModel.markOrderComplete) + .font(.subheadline) + .tint(Color(.primary)) + } + if isShipmentDetailsExpanded || viewModel.selectedPackage != nil { + purchaseButton + } + } + } + else { + HStack(spacing: Layout.bottomSheetSpacing) { + if viewModel.selectedPackage != nil || isShipmentDetailsExpanded { + Toggle(Localization.BottomSheet.markComplete, isOn: $viewModel.markOrderComplete) + .font(.subheadline) + .tint(Color(.primary)) + .fixedSize(horizontal: false, vertical: true) + purchaseButton + } + } } } - .padding(.horizontal, Layout.bottomSheetPadding) } + .padding(.horizontal, Layout.bottomSheetPadding) } expandableContent: { VStack(alignment: .leading, spacing: Layout.bottomSheetSpacing) { if isiPhonePortrait { @@ -95,10 +114,20 @@ struct WooShippingCreateLabelsView: View { HStack(alignment: .firstTextBaseline, spacing: Layout.bottomSheetSpacing) { Text(Localization.BottomSheet.shipFrom) .trackSize(size: $shipmentDetailsShipFromSize) - Text(viewModel.originAddress) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) + Button { + isOriginAddressListPresented = true + } label: { + HStack { + Text(viewModel.originAddress) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "ellipsis") + .frame(width: Layout.ellipsisWidth) + .bold() + } + } + .buttonStyle(TextButtonStyle()) } .padding(Layout.bottomSheetPadding) Divider() @@ -141,7 +170,14 @@ struct WooShippingCreateLabelsView: View { .padding([.bottom, .horizontal], Layout.bottomSheetPadding) } .ignoresSafeArea(edges: .horizontal) + .sheet(isPresented: $isOriginAddressListPresented) { + WooShippingOriginAddressListView(viewModel: viewModel.originAddresses) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } } + .shippingWeightUnit(viewModel.weightUnit) + .shippingDimensionsUnit(viewModel.dimensionsUnit) .navigationTitle(viewModel.canViewLabel ? Localization.viewLabelTitle : Localization.title) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -152,10 +188,6 @@ struct WooShippingCreateLabelsView: View { } } } - .onAppear() { - guard viewModel.storeOptions == nil else { return } - viewModel.loadStoreOptions() - } } } @@ -234,6 +266,22 @@ private extension WooShippingCreateLabelsView { } } +// MARK: Store Options +extension EnvironmentValues { + @Entry var shippingWeightUnit: String = ServiceLocator.shippingSettingsService.weightUnit ?? "" + @Entry var shippingDimensionsUnit: String = ServiceLocator.shippingSettingsService.dimensionUnit ?? "" +} + +extension View { + func shippingWeightUnit(_ weightUnit: String) -> some View { + environment(\.shippingWeightUnit, weightUnit) + } + + func shippingDimensionsUnit(_ dimensionsUnit: String) -> some View { + environment(\.shippingDimensionsUnit, dimensionsUnit) + } +} + private extension WooShippingCreateLabelsView { enum Layout { static let verticalSpacing: CGFloat = 8 @@ -241,6 +289,7 @@ private extension WooShippingCreateLabelsView { static let iconSize: CGFloat = 32 static let rowHeight: CGFloat = 32 static let chevronSize: CGFloat = 30 + static let ellipsisWidth: CGFloat = 22 static let bottomSheetSpacing: CGFloat = 16 static let bottomSheetPadding: CGFloat = 16 } @@ -284,8 +333,8 @@ private extension WooShippingCreateLabelsView { static let total = NSLocalizedString("wooShipping.createLabels.bottomSheet.total", value: "Total", comment: "Label for row showing the total for shipment costs on the shipping label creation screen") - static let markComplete = NSLocalizedString("wooShipping.createLabels.bottomSheet.markComplete", - value: "Mark this order complete and notify the customer", + static let markComplete = NSLocalizedString("wooShipping.createLabels.bottomSheet.afterPurchaseMarkComplete", + value: "After purchasing a label, mark this order as complete and notify the customer", comment: "Label for the toggle to mark the order as complete on the shipping label creation screen") static let paperSize = NSLocalizedString("wooShipping.createLabels.bottomSheet.paperSize", value: "Choose label paper size", diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift index 33545d5ede0..5f643ec3fc2 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift @@ -1,6 +1,7 @@ import Foundation import Yosemite import WooFoundation +import Combine /// Provides view data for `WooShippingCreateLabelsView`. /// @@ -8,9 +9,10 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { private let currencyFormatter: CurrencyFormatter private let order: Order private let itemsDataSource: WooShippingItemsDataSource - private let originSiteAddress: ShippingLabelAddress? private let destinationAddress: ShippingLabelAddress? private let stores: StoresManager + private var subscriptions: Set = [] + private var debounceDuration: Double = 1 /// The purchased shipping label. @Published private var shippingLabel: ShippingLabel? @@ -20,14 +22,28 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { shippingLabel != nil } + /// Whether the custom information is completed or not. + var customsInformationIsCompleted: Bool { + // To be synced with real data + true + } + /// View model for the section displayed after a shipping label is purchased. @Published private(set) var postPurchase: WooShippingPostPurchaseViewModel? /// View model for the items to ship. @Published private(set) var items: WooShippingItemsViewModel - /// Selected package for the shipping label. - @Published private(set) var selectedPackage: ShippingLabelPackageSelected? + /// ID for the shipment. + /// + /// For now we support purchasing labels in a single shipment only, so we only need a single shipment ID. + let shipmentID = "shipment_0" + + /// Selected package data for the shipping label. + @Published private(set) var selectedPackage: WooShippingPackageDataRepresentable? + + /// String representing the total weight for the shipment. + @Published var shipmentWeight: String = "" /// View model for the label shipping service. private(set) var shippingService: WooShippingServiceViewModel? @@ -35,10 +51,14 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { /// Selected shipping rate when creating a shipping label. @Published private var selectedRate: WooShippingSelectedRate? + /// View model for a list of origin addresses to ship from. + private(set) var originAddresses = WooShippingOriginAddressListViewModel(addresses: []) + + /// Address to ship from (store address). + @Published private var selectedOriginAddress: WooShippingOriginAddress? + /// Address to ship from (store address), formatted for display. - private(set) lazy var originAddress: String = { - originSiteAddress?.formattedPostalAddress?.replacingOccurrences(of: "\n", with: ", ") ?? "" - }() + @Published private(set) var originAddress: String = "" /// Address to ship to (customer address), formatted for display and split into separate lines to allow additional formatting. private(set) lazy var destinationAddressLines: [String] = { @@ -80,82 +100,55 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { /// If the purchase button should be enabled. var isPurchaseButtonEnabled: Bool { - selectedRate != nil && shippingLabel == nil + // Don't allow purchasing if a label is already purchased + shippingLabel == nil + // or if any required fields are missing + && selectedOriginAddress != nil && destinationAddress != nil && selectedPackage != nil && selectedRate != nil } /// If the label purchase is in progress. @Published private(set) var isPurchasingLabel: Bool = false - @Published var storeOptions: ShippingLabelStoreOptions? - @Published var isLoadingStoreOptions: Bool = false - - func loadStoreOptions(completion: ((ShippingLabelStoreOptions) -> Void)? = nil) { - guard isLoadingStoreOptions == false, - let siteID = ServiceLocator.stores.sessionManager.defaultStoreID else { return } + /// Unit to use for weight measurements. + @Published var weightUnit: String = ServiceLocator.shippingSettingsService.weightUnit ?? "" - isLoadingStoreOptions = true - - let action = WooShippingAction.loadAccountSettings(siteID: siteID) { result in - switch result { - case .success(let settings): - self.storeOptions = settings.storeOptions - completion?(settings.storeOptions) - case .failure(let error): - DDLogError("⛔️ Error loading account settings: \(error)") - // fallback to store settings - let shippingSettingsService = ServiceLocator.shippingSettingsService - let currencySettings = ServiceLocator.currencySettings - let currencySymbol = currencySettings.symbol(from: currencySettings.currencyCode) - let originCountry = SiteAddress().countryCode.rawValue - if let dimensionUnit = shippingSettingsService.dimensionUnit, - let weightUnit = shippingSettingsService.weightUnit { - let fallbackStoreOptions = ShippingLabelStoreOptions(currencySymbol: currencySymbol, - dimensionUnit: dimensionUnit, - weightUnit: weightUnit, - originCountry: originCountry) - self.storeOptions = fallbackStoreOptions - } - } - self.isLoadingStoreOptions = false - } - ServiceLocator.stores.dispatch(action) - } + /// Unit to use for dimensions measurements. + @Published var dimensionsUnit: String = ServiceLocator.shippingSettingsService.dimensionUnit ?? "" /// Closure to execute after the label is successfully purchased. let onLabelPurchase: ((_ markOrderComplete: Bool) -> Void)? /// Initialize the view model without an existing shipping label. init(order: Order, - originAddress: SiteAddress? = nil, - selectedPackage: ShippingLabelPackageSelected? = nil, + selectedOriginAddress: WooShippingOriginAddress? = nil, + selectedPackage: WooShippingPackageDataRepresentable? = nil, selectedRate: WooShippingSelectedRate? = nil, currencySettings: CurrencySettings = ServiceLocator.currencySettings, userDefaults: UserDefaults = .standard, stores: StoresManager = ServiceLocator.stores, + itemsDataSource: WooShippingItemsDataSource? = nil, + debounceDuration: Double = 1, onLabelPurchase: ((Bool) -> Void)? = nil) { self.order = order - self.itemsDataSource = DefaultWooShippingItemsDataSource(order: order) + let itemsDataSource = itemsDataSource ?? DefaultWooShippingItemsDataSource(order: order) + self.itemsDataSource = itemsDataSource self.items = WooShippingItemsViewModel(dataSource: itemsDataSource) self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) self.onLabelPurchase = onLabelPurchase - let accountSettings = Self.getStoredAccountSettings() - let company = ServiceLocator.stores.sessionManager.defaultSite?.name - let defaultAccount = ServiceLocator.stores.sessionManager.defaultAccount - self.originSiteAddress = Self.getDefaultOriginAddress(accountSettings: accountSettings, - company: company, - siteAddress: originAddress ?? SiteAddress(), - account: defaultAccount, - userDefaults: userDefaults) self.destinationAddress = Self.getDestinationAddress(order: order, address: order.shippingAddress) self.shippingLines = order.shippingLines.map({ WooShipping_ShippingLineViewModel(shippingLine: $0, currency: order.currency) }) + self.selectedOriginAddress = selectedOriginAddress self.selectedPackage = selectedPackage self.selectedRate = selectedRate self.stores = stores - shippingService = WooShippingServiceViewModel(order: order, - originAddress: originSiteAddress, - destinationAddress: destinationAddress) { [weak self] selectedRate in - self?.selectedRate = selectedRate - } + self.debounceDuration = debounceDuration + + observeSelectedOriginAddress() + observeSelectedPackage() + observeForLabelRates() + loadStoreOptions() + loadPackages() + loadOriginAddresses() } /// Initialize the view model from an existing shipping label. @@ -170,7 +163,7 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { self.itemsDataSource = DefaultWooShippingItemsDataSource(order: order) self.items = WooShippingItemsViewModel(dataSource: itemsDataSource) self.shippingLines = order.shippingLines.map({ WooShipping_ShippingLineViewModel(shippingLine: $0, currency: order.currency) }) - self.originSiteAddress = shippingLabel.originAddress + self.originAddress = shippingLabel.originAddress.formattedPostalAddress?.replacingOccurrences(of: "\n", with: ", ") ?? "" self.destinationAddress = shippingLabel.destinationAddress self.onLabelPurchase = nil self.stores = stores @@ -179,38 +172,26 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { /// Handles package selection for the shipping label. /// Selecting a package also refreshes the available rates for the shipping service. func selectPackage(_ packageData: WooShippingPackageDataRepresentable) { - // For now we support purchasing labels in a single package for a single shipment. - // In future milestones we can handle an array of packages with unique IDs for each shipment. - let package = ShippingLabelPackageSelected(id: "shipment_0", - boxID: packageData.id, - length: Double(packageData.length) ?? 0, - width: Double(packageData.width) ?? 0, - height: Double(packageData.height) ?? 0, - weight: itemsDataSource.items.map(\.weight).reduce(0, +) + (Double(packageData.weight) ?? 0), - isLetter: WooShippingPackageType(rawValue: packageData.packageType) == .envelope, - hazmatCategory: nil, // Hazmat support will be added in a future milestone - customsForm: nil) // Customs form support will be added in a future milestone - selectedPackage = package - shippingService?.loadLabelRates(for: package) + selectedPackage = packageData } /// Purchases a shipping label with the provided label details and settings. func purchaseLabel() { - guard isPurchaseButtonEnabled, !isPurchasingLabel, let originSiteAddress, let destinationAddress, let selectedPackage, let selectedRate else { + guard isPurchaseButtonEnabled, !isPurchasingLabel, let selectedOriginAddress, let destinationAddress, let selectedPackage, let selectedRate else { return } isPurchasingLabel = true - // For now we support purchasing labels in a single shipment only. - // In future milestones we can create an array of `WooShippingPackagePurchase` with unique shipment IDs for each shipment. - let package = WooShippingPackagePurchase(shipmentID: "shipment_0", - package: selectedPackage, - rate: selectedRate.purchaseRate, - productIDs: itemsDataSource.items.map(\.productOrVariationID)) + let packagePurchase = WooShippingPackagePurchase(shipmentID: shipmentID, + package: fromPackageDataToPackageSelected(selectedPackage, + weight: Double(shipmentWeight) ?? 0, + shipmentID: shipmentID), + rate: selectedRate.purchaseRate, + productIDs: itemsDataSource.items.map(\.productOrVariationID)) let action = WooShippingAction.purchaseShippingLabel(siteID: order.siteID, orderID: order.orderID, - originAddress: originSiteAddress, + originAddress: selectedOriginAddress.toShippingLabelAddress(), destinationAddress: destinationAddress, - package: package) { [weak self] result in + package: packagePurchase) { [weak self] result in guard let self else { return } isPurchasingLabel = false switch result { @@ -224,10 +205,103 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { } stores.dispatch(action) } + + /// Updates store options (weight and dimensions units) with remote settings. + func loadStoreOptions() { + guard let siteID = stores.sessionManager.defaultStoreID else { return } + + let action = WooShippingAction.loadAccountSettings(siteID: siteID) { [weak self] result in + switch result { + case .success(let settings): + guard let self else { return } + weightUnit = settings.storeOptions.weightUnit + dimensionsUnit = settings.storeOptions.dimensionUnit + case .failure(let error): + DDLogError("⛔️ Error loading account settings: \(error)") + } + } + stores.dispatch(action) + } +} + +// MARK: Remote +private extension WooShippingCreateLabelsViewModel { + /// Syncs packages to use for shipping label from remote. + /// + func loadPackages() { + let action = WooShippingAction.loadPackages(siteID: order.siteID) { result in + if case .failure(let error) = result { + DDLogError("⛔️ Error loading packages for Woo Shipping labels: \(error)") + } + } + stores.dispatch(action) + } + + /// Syncs origin addresses to use for shipping label from remote. + /// + func loadOriginAddresses() { + let action = WooShippingAction.loadOriginAddresses(siteID: order.siteID) { [weak self] result in + guard let self else { return } + switch result { + case .success(let addresses): + selectedOriginAddress = addresses.first(where: \.defaultAddress) + originAddresses = WooShippingOriginAddressListViewModel(addresses: addresses, + selectedAddressID: selectedOriginAddress?.id) + originAddresses.onSelect = { [weak self] selectedAddress in + self?.selectedOriginAddress = selectedAddress + } + case .failure(let error): + DDLogError("⛔️ Error loading origin addresses for Woo Shipping labels: \(error)") + } + } + stores.dispatch(action) + } } // MARK: Utils private extension WooShippingCreateLabelsViewModel { + /// Observes the selected package and updates the shipment weight. + func observeSelectedPackage() { + let itemsWeight = itemsDataSource.items.map { $0.weight * Double(truncating: $0.quantity as NSDecimalNumber) }.reduce(0, +) + $selectedPackage + .map { selectedPackage in + guard let selectedPackage else { + return itemsWeight.description + } + return (itemsWeight + (Double(selectedPackage.weight) ?? 0)).description + } + .assign(to: &$shipmentWeight) + } + + /// Observes the selected origin address and updates the displayed origin address and shipping service. + func observeSelectedOriginAddress() { + $selectedOriginAddress + .sink { [weak self] selectedOriginAddress in + guard let self else { return } + originAddress = selectedOriginAddress?.formattedPostalAddress ?? "" + shippingService = WooShippingServiceViewModel(order: order, + originAddress: selectedOriginAddress?.toShippingLabelAddress(), + destinationAddress: destinationAddress, + stores: stores) { [weak self] selectedRate in + self?.selectedRate = selectedRate + } + } + .store(in: &subscriptions) + } + + /// Observes the selected package and shipment weight and requests the available shipping rates. + func observeForLabelRates() { + $shipmentWeight + .debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main) + .removeDuplicates() + .combineLatest($selectedPackage) + .sink { [weak self] weight, selectedPackage in + guard let self, let selectedPackage, let shippingService else { return } + shippingService.loadLabelRates(for: fromPackageDataToPackageSelected(selectedPackage, weight: Double(weight) ?? 0, shipmentID: shipmentID)) + } + .store(in: &subscriptions) + } + /// Provides the formatted label and amount for a shipping rate, based on the provided base rate. func formatShippingRate(name: String, rate: Double, basedOn baseRate: Double? = nil) -> (title: String, amount: String) { let amount = { @@ -299,6 +373,19 @@ private extension WooShippingCreateLabelsViewModel { try? resultsController.performFetch() return resultsController.fetchedObjects.first } + + /// Converts the package data to a `ShippingLabelPackageSelected` object. + func fromPackageDataToPackageSelected(_ packageData: WooShippingPackageDataRepresentable, weight: Double, shipmentID: String) -> ShippingLabelPackageSelected { + ShippingLabelPackageSelected(id: shipmentID, + boxID: packageData.id, + length: Double(packageData.length) ?? 0, + width: Double(packageData.width) ?? 0, + height: Double(packageData.height) ?? 0, + weight: weight, + isLetter: WooShippingPackageType(rawValue: packageData.packageType) == .envelope, + hazmatCategory: nil, // Hazmat support will be added in a future milestone + customsForm: nil) // Customs form support will be added in a future milestone + } } private extension WooShippingCreateLabelsViewModel { @@ -324,3 +411,21 @@ private extension WooShippingCreateLabelsViewModel { "on the shipping label creation screen") } } + +private extension WooShippingOriginAddress { + /// Converts the origin address to a `ShippingLabelAddress`. + /// + /// This prepares the address for use in e.g. fetching available shipping rates or purchasing the label. + /// + func toShippingLabelAddress() -> ShippingLabelAddress { + ShippingLabelAddress(company: company, + name: fullName ?? "", + phone: phone, + country: country, + state: state, + address1: address1, + address2: address2, + city: city, + postcode: postcode) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift index 239c287817f..929f6655cad 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift @@ -57,11 +57,12 @@ final class FilterOrderListViewModel: FilterListViewModel { let filterTypeViewModels: [FilterTypeViewModel] + let shouldShowHistory: Bool + private let orderStatusFilterViewModel: FilterTypeViewModel private let dateRangeFilterViewModel: FilterTypeViewModel private let productFilterViewModel: FilterTypeViewModel private let customerFilterViewModel: FilterTypeViewModel - private let featureFlagService: FeatureFlagService /// - Parameters: /// - filters: the filters to be applied initially. @@ -77,7 +78,7 @@ final class FilterOrderListViewModel: FilterListViewModel { productFilterViewModel = OrderListFilter.product(siteID: siteID).createViewModel(filters: filters, allowedStatuses: allowedStatuses) customerFilterViewModel = OrderListFilter.customer(siteID: siteID).createViewModel(filters: filters, allowedStatuses: allowedStatuses) - self.featureFlagService = featureFlagService + shouldShowHistory = featureFlagService.isFeatureFlagEnabled(.filterHistoryOnOrderAndProductLists) filterTypeViewModels = [orderStatusFilterViewModel, dateRangeFilterViewModel, customerFilterViewModel, productFilterViewModel] } diff --git a/WooCommerce/Classes/ViewRelated/Orders/OrderListViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/OrderListViewController.swift index 1dd40196b8b..5b0548c4108 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/OrderListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/OrderListViewController.swift @@ -140,10 +140,6 @@ final class OrderListViewController: UIViewController, GhostableViewController { /// private var selectedOrderID: Int64? - /// Tracks if the swipe actions have been glanced to the user. - /// - private var swipeActionsGlanced = false - /// Banner variation that will be shown as In-Person Payments feedback banner. If any. /// private var inPersonPaymentsSurveyVariation: SurveyViewController.Source? @@ -415,16 +411,6 @@ extension OrderListViewController { let presenter = OrderFulfillmentNoticePresenter(noticeConfiguration: noticeConfiguration) presenter.present(process: fulfillmentProcess) } - - /// Slightly reveal swipe actions of the first visible cell that contains at least one swipe action. - /// This action is performed only once, using `swipeActionsGlanced` as a control variable. - /// - private func glanceTrailingActionsIfNeeded() { - if !swipeActionsGlanced { - swipeActionsGlanced = true - tableView.glanceTrailingSwipeActions() - } - } } // MARK: - Sync'ing Helpers @@ -928,7 +914,7 @@ private extension OrderListViewController { case .syncing: ensureFooterSpinnerIsStarted() case .results: - glanceTrailingActionsIfNeeded() + break } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsView.swift b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsView.swift index 5a6b9c3653e..a1353375826 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsView.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsView.swift @@ -44,6 +44,16 @@ struct PaymentMethodsView: View { VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: Layout.noSpacing) { + if viewModel.showTapToPayRow { + MethodRow(icon: .tapToPayOnIPhoneIcon, + title: Localization.tapToPay, + accessibilityID: Accessibility.tapToPayMethod) { + viewModel.collectPayment(using: .localMobile, on: rootViewController, onSuccess: dismiss, onFailure: dismiss) + } + + Divider() + } + MethodRow(icon: .priceImage, title: Localization.cash, accessibilityID: Accessibility.cashMethod) { showingCashAlert = true viewModel.trackCollectByCash() @@ -57,16 +67,6 @@ struct PaymentMethodsView: View { } } - if viewModel.showTapToPayRow { - Divider() - - MethodRow(icon: .tapToPayOnIPhoneIcon, - title: Localization.tapToPay, - accessibilityID: Accessibility.tapToPayMethod) { - viewModel.collectPayment(using: .localMobile, on: rootViewController, onSuccess: dismiss, onFailure: dismiss) - } - } - if viewModel.showPaymentLinkRow { Divider() diff --git a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift index 353c347d439..b7c425278a1 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModel.swift @@ -215,6 +215,7 @@ final class PaymentMethodsViewModel: ObservableObject { return presentNoticeSubject.send(.error(Localization.genericCollectError)) } let alertsPresenter = CardPresentPaymentAlertsPresenter(rootViewController: rootViewController) + let merchantEducationPresenter = BuiltInCardReaderMerchantEducationPresenter(rootViewController: rootViewController) let analyticsTracker = CardReaderConnectionAnalyticsTracker( configuration: cardPresentPaymentsConfiguration, siteID: siteID, @@ -233,6 +234,7 @@ final class PaymentMethodsViewModel: ObservableObject { forSiteID: siteID, alertsPresenter: alertsPresenter, alertsProvider: tapToPayAlertsProvider, + merchantEducationPresenter: merchantEducationPresenter, configuration: cardPresentPaymentsConfiguration, analyticsTracker: analyticsTracker) @@ -262,11 +264,17 @@ final class PaymentMethodsViewModel: ObservableObject { using: discoveryMethod, channel: channel, onFailure: { [weak self] error in - self?.trackFlowFailed() + guard let self else { return } + + trackFlowFailed() // Update order in case its status and/or other details are updated after a failed in-person payment - self?.updateOrderAsynchronously() + updateOrderAsynchronously() + + if shouldReturnToOrderDetails(for: discoveryMethod, error: error) { + onFailure() + return + } - onFailure() }, onCancel: { // No tracking required because the flow remains on screen to choose other payment methods. @@ -531,6 +539,20 @@ private extension PaymentMethodsViewModel { } } +private extension PaymentMethodsViewModel { + /// Determines if the flow should return to the order details screen after a failed payment attempt. + /// For some specific errors that require the user to select a different payment method, we should not return to the order details screen. + /// + func shouldReturnToOrderDetails(for discoveryMethod: CardReaderDiscoveryMethod, error: Error) -> Bool { + switch (discoveryMethod, error) { + case (.localMobile, let error as CardPaymentErrorProtocol) where error.requiresFallbackPaymentMethod: + return false + default: + return true + } + } +} + private extension PaymentMethodsViewModel { enum Localization { static let markAsPaidError = NSLocalizedString("There was an error while marking the order as paid.", diff --git a/WooCommerce/Classes/ViewRelated/Products/Filters/FilterProductListViewModel.swift b/WooCommerce/Classes/ViewRelated/Products/Filters/FilterProductListViewModel.swift index add718716bc..f0bcbd9594a 100644 --- a/WooCommerce/Classes/ViewRelated/Products/Filters/FilterProductListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Products/Filters/FilterProductListViewModel.swift @@ -52,6 +52,8 @@ final class FilterProductListViewModel: FilterListViewModel { let filterTypeViewModels: [FilterTypeViewModel] + let shouldShowHistory: Bool + private let stockStatusFilterViewModel: FilterTypeViewModel private let productStatusFilterViewModel: FilterTypeViewModel private let productTypeFilterViewModel: FilterTypeViewModel @@ -73,6 +75,7 @@ final class FilterProductListViewModel: FilterListViewModel { self.productTypeFilterViewModel = ProductListFilter.productType(siteID: siteID).createViewModel(filters: filters) self.productCategoryFilterViewModel = ProductListFilter.productCategory(siteID: siteID).createViewModel(filters: filters) self.productFavoriteFilterViewModel = ProductListFilter.favoriteProducts.createViewModel(filters: filters) + self.shouldShowHistory = false if featureFlagService.isFeatureFlagEnabled(.favoriteProducts) { self.filterTypeViewModels = [ diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift index c176dd72406..a475eec8586 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductsViewController.swift @@ -1140,8 +1140,6 @@ extension ProductsViewController: UITableViewDelegate { } func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - let productIndex = resultsController.objectIndex(from: indexPath) - // Preserve the Cell Height // Why: Because Autosizing Cells, upon reload, will need to be laid yout yet again. This might cause // UI glitches / unwanted animations. By preserving it, *then* the estimated will be extremely close to diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/FormattableAmountTextField.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/FormattableAmountTextField.swift index 2805ed01ad1..d6f6f87b07a 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/FormattableAmountTextField.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/FormattableAmountTextField.swift @@ -7,9 +7,11 @@ struct FormattableAmountTextField: View { @FocusState private var focusAmountInput: Bool @ObservedObject private var viewModel: FormattableAmountTextFieldViewModel + private let style: Style - init(viewModel: FormattableAmountTextFieldViewModel) { + init(viewModel: FormattableAmountTextFieldViewModel, style: Style = .default) { self.viewModel = viewModel + self.style = style } var body: some View { @@ -27,9 +29,9 @@ struct FormattableAmountTextField: View { .foregroundColor(Color(viewModel.amountTextColor)) .minimumScaleFactor(0.1) .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: style.textAlignment) .padding(5) - .if(focusAmountInput, transform: { field in + .if(focusAmountInput && style.showsBorder, transform: { field in field.roundedBorder(cornerRadius: 8, lineColor: Color(.wooCommercePurple(.shade60)), lineWidth: 1) }) .onTapGesture { @@ -40,6 +42,31 @@ struct FormattableAmountTextField: View { } } +extension FormattableAmountTextField { + enum Style { + case `default` + case pos + + var showsBorder: Bool { + switch self { + case .default: + return true + case .pos: + return false + } + } + + var textAlignment: Alignment { + switch self { + case .default: + return .leading + case .pos: + return .center + } + } + } +} + private extension FormattableAmountTextField { enum Layout { static func amountFontSize(size: CGFloat, scale: CGFloat) -> CGFloat { diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SelectableItemRow.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SelectableItemRow.swift index be34ec5deaf..d1b2a1c0d7a 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SelectableItemRow.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SelectableItemRow.swift @@ -7,6 +7,7 @@ struct SelectableItemRow: View { private let selected: Bool private let displayMode: DisplayMode private let alignment: Alignment + private let verticalSpacing: CGFloat private let selectionStyle: SelectionStyle @Environment(\.isEnabled) private var isEnabled @@ -15,12 +16,14 @@ struct SelectableItemRow: View { selected: Bool, displayMode: DisplayMode = .full, alignment: Alignment = .leading, + verticalSpacing: CGFloat = 16, selectionStyle: SelectionStyle = .checkmark) { self.title = title self.subtitle = subtitle self.selected = selected self.displayMode = displayMode self.alignment = alignment + self.verticalSpacing = verticalSpacing self.selectionStyle = selectionStyle } var body: some View { @@ -29,14 +32,13 @@ struct SelectableItemRow: View { selectionIcon } - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: verticalSpacing) { Text(title) .bodyStyle(isEnabled) .multilineTextAlignment(.leading) subtitle.map { Text($0) .footnoteStyle(isEnabled: isEnabled) - .padding(.top, 8) } } .padding(.leading, alignment.leadingSpace) diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift index a34de43bc3d..e8548413c8a 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/TopTabView.swift @@ -127,10 +127,9 @@ struct TopTabView: View { }) } .onAppear { - withAnimation { - scrollViewProxy.scrollTo(selectedTab, anchor: .center) - underlineOffset = calculateOffset(index: selectedTab) - } + selectedTab = selectedTabIndex ?? 0 + scrollViewProxy.scrollTo(selectedTab, anchor: .center) + underlineOffset = calculateOffset(index: selectedTab) } } .padding(.horizontal, tabPadding) @@ -143,8 +142,15 @@ struct TopTabView: View { alignment: .bottomLeading ) .onChange(of: selectedTab, perform: { newSelectedTab in + let animate = selectedTabIndex != newSelectedTab selectedTabIndex = newSelectedTab - withAnimation { + if animate { + withAnimation { + scrollViewProxy.scrollTo(newSelectedTab, anchor: .center) + underlineOffset = calculateOffset(index: newSelectedTab) + } + } + else { scrollViewProxy.scrollTo(newSelectedTab, anchor: .center) underlineOffset = calculateOffset(index: newSelectedTab) } diff --git a/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift b/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift index 944f5c8c9b0..22f1a2e6bc7 100644 --- a/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Reviews/ReviewsViewController.swift @@ -100,6 +100,8 @@ final class ReviewsViewController: UIViewController, GhostableViewController { /// private var topBannerView: TopBannerView? + private var lastSelectedItemIndexPath: IndexPath? + // MARK: - Initializers // convenience init(siteID: Int64) { @@ -159,6 +161,12 @@ final class ReviewsViewController: UIViewController, GhostableViewController { self.removeGhostContent() self.displayGhostContent() } + + // Reload last selected row to update highlight state + if let lastSelectedItemIndexPath { + tableView.reloadRows(at: [lastSelectedItemIndexPath], with: .none) + self.lastSelectedItemIndexPath = nil + } } override var shouldShowOfflineBanner: Bool { @@ -298,6 +306,7 @@ extension ReviewsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) + lastSelectedItemIndexPath = indexPath viewModel.delegate.didSelectItem(at: indexPath, in: self) } diff --git a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift index a842e9bc7df..740713b2a39 100644 --- a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift +++ b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift @@ -250,8 +250,8 @@ class DefaultStoresManager: StoresManager { dispatch(resetAction) } - state = DeauthenticatedState() sessionManager.reset() + state = DeauthenticatedState() ServiceLocator.analytics.refreshUserData() ZendeskProvider.shared.reset() diff --git a/WooCommerce/Resources/AppStoreStrings.pot b/WooCommerce/Resources/AppStoreStrings.pot index d768595a732..1078eaa6000 100644 --- a/WooCommerce/Resources/AppStoreStrings.pot +++ b/WooCommerce/Resources/AppStoreStrings.pot @@ -61,11 +61,11 @@ msgctxt "app_store_promo_text" msgid "Run your store from anywhere" msgstr "" -msgctxt "v21.2-whats-new" +msgctxt "v21.3-whats-new" msgid "" -"In just two weeks, we've jam-packed this release. There's GTIN global product identifier support, and you can edit the call-to-action in Blaze campaigns.\n" "\n" -"Store setup for in-person payments is faster, receipt sent confirmations clearer, and testing Tap to Pay on iPhone is smoother. Plus, we've improved dashboard statistics and squashed some bugs.\n" +"This update brings enhanced reliability and clarity to your WooCommerce experience! Enjoy improved Jetpack setup, smoother media handling, and better product and payment workflows. We’ve also optimized storage and addressed key UI issues to elevate performance. Plus, Tap to Pay onboarding now guides you with ease!\n" +"\n" msgstr "" #. translators: This is a promo message that will be attached on top of a screenshot in the App Store. diff --git a/WooCommerce/Resources/Images.xcassets/POS/pos-success-check.imageset/pos-success-check.pdf b/WooCommerce/Resources/Images.xcassets/POS/pos-success-check.imageset/pos-success-check.pdf index 893abe54bbf..eaaf892fbde 100644 Binary files a/WooCommerce/Resources/Images.xcassets/POS/pos-success-check.imageset/pos-success-check.pdf and b/WooCommerce/Resources/Images.xcassets/POS/pos-success-check.imageset/pos-success-check.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/Contents.json new file mode 100644 index 00000000000..6e565ce5ba9 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-apple-pay-gb.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/tap-to-pay-education-apple-pay-gb.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/tap-to-pay-education-apple-pay-gb.pdf new file mode 100644 index 00000000000..c94c5add61b Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-gb.imageset/tap-to-pay-education-apple-pay-gb.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/Contents.json new file mode 100644 index 00000000000..265a5a5af4c --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-apple-pay-us.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/tap-to-pay-education-apple-pay-us.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/tap-to-pay-education-apple-pay-us.pdf new file mode 100644 index 00000000000..07bc453d01f Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Apple Pay/tap-to-pay-education-apple-pay-us.imageset/tap-to-pay-education-apple-pay-us.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/Contents.json new file mode 100644 index 00000000000..ceb2a06d52a --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-contactless-cards-gb.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/tap-to-pay-education-contactless-cards-gb.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/tap-to-pay-education-contactless-cards-gb.pdf new file mode 100644 index 00000000000..c17eef0384e Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-gb.imageset/tap-to-pay-education-contactless-cards-gb.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/Contents.json new file mode 100644 index 00000000000..7bc59e472d9 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-contactless-cards-us.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/tap-to-pay-education-contactless-cards-us.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/tap-to-pay-education-contactless-cards-us.pdf new file mode 100644 index 00000000000..0b337f2044b Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contactless Cards/tap-to-pay-education-contactless-cards-us.imageset/tap-to-pay-education-contactless-cards-us.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/Contents.json new file mode 100644 index 00000000000..c7fffc8394c --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-fallback-payment-method-gb-light.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "tap-to-pay-education-fallback-payment-method-gb-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-dark.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-dark.pdf new file mode 100644 index 00000000000..31b8cb421b2 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-dark.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-light.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-light.pdf new file mode 100644 index 00000000000..8d6a27c440c Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Fallback Payment Method/tap-to-pay-education-fallback-payment-method-gb.imageset/tap-to-pay-education-fallback-payment-method-gb-light.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/Contents.json new file mode 100644 index 00000000000..628d9762b78 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-intro-gb.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/tap-to-pay-education-intro-gb.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/tap-to-pay-education-intro-gb.pdf new file mode 100644 index 00000000000..4bfdaa6a240 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-gb.imageset/tap-to-pay-education-intro-gb.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/Contents.json new file mode 100644 index 00000000000..f18331aaaef --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-intro-us.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/tap-to-pay-education-intro-us.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/tap-to-pay-education-intro-us.pdf new file mode 100644 index 00000000000..2d4549a94f2 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/Intro/tap-to-pay-education-intro-us.imageset/tap-to-pay-education-intro-us.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/Contents.json new file mode 100644 index 00000000000..73fafd6094b --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "tap-to-pay-education-pin-gb.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/tap-to-pay-education-pin-gb.pdf b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/tap-to-pay-education-pin-gb.pdf new file mode 100644 index 00000000000..e83a6025abb Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/Tap to Pay Education/PIN/tap-to-pay-education-pin-gb.imageset/tap-to-pay-education-pin-gb.pdf differ diff --git a/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/Contents.json new file mode 100644 index 00000000000..8ac9b945ce9 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "card-reader-location-permission.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/card-reader-location-permission.pdf b/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/card-reader-location-permission.pdf new file mode 100644 index 00000000000..512337d8f02 Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/card-reader-location-permission.imageset/card-reader-location-permission.pdf differ diff --git a/WooCommerce/Resources/ar.lproj/InfoPlist.strings b/WooCommerce/Resources/ar.lproj/InfoPlist.strings index ca481fb22d1..a9e5c926aab 100644 --- a/WooCommerce/Resources/ar.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/ar.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ لالتقاط صور أو مقاطع فيديو لإضافتها إلى منتجاتك، قم بإجراء مسح ضوئي للرمز الشريطي الخاص بوحدة SKU للمنتج أو تذاكر الدعم NSLocationWhenInUseUsageDescription يلزم الوصول إلى الموقع لقبول المدفوعات. + NSMicrophoneUsageDescription + يستخدم Woo الميكروفون للسماح لك بالتقاط الصوت عند تسجيل الفيديوهات لمكتبة الوسائط في متجرك. NSPhotoLibraryUsageDescription لحفظ الصور من كاميرا خاصة بصور المنتجات أو لإضافة صور أو مقاطع فيديو إلى منتجاتك أو تذاكر الدعم الخاصة بك. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/ar.lproj/Localizable.strings b/WooCommerce/Resources/ar.lproj/Localizable.strings index ed9355f64fc..64ac98542ae 100644 --- a/WooCommerce/Resources/ar.lproj/Localizable.strings +++ b/WooCommerce/Resources/ar.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-11 18:54:03+0000 */ +/* Translation-Revision-Date: 2024-11-28 14:54:04+0000 */ /* Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: ar */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "عائلة Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "يتم إيداع الأموال المتوافرة تلقائيًا عند الطلب، %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "يتم دفع الأموال المتوفرة تلقائيًا، %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "يتم إيداع الأموال المتوافرة يدويًا عند الطلب."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "يتم دفع الأموال المتوفرة يدويًا عند الطلب."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "متوسط قيمة الطلب"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "سطر مخصص ⁦%1$d⁩"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "حزمة مخصصة"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "خطأ في أثناء تحديث الطلب رقم ⁦%1$d⁩"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "%1$@ مقدَّر"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "قائمة الميزات الكاملة"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "تصبح الأموال متوافرة بعد تعليقها لمدة ⁦%1$d⁩ من الأيام."; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "المخزون"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "ميانمار"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "غير محدَّد"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "يتعذر استرداد الإيصال."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "عذرًا، لا يمكنك سوى تحرير هذا الطلب على الويب؛ لأنه يستخدم %1$@، وعملة موقعك هي %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "كل %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "عنوان الشحن"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "وحدة SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "إلغاء"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "نص CTA"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "حثّ على المبادرة"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "نص الوصف الخاص بـ Blaze Ad"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "تحرير الإعلان"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "يتعذر أن يتجاوز نص CTA ⁦%1$d⁩ من الأحرف"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "يتعذر أن يكون الوصف فارغًا"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "الفحص بحثًا عن قارئ"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "تم إرسال الإيصال إلى %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "إرسال الإيصال عبر البريد الإلكتروني"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "نجح الدفع"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "طباعة الإيصال"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "تم إرسال الإيصال إلى %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "حفظ الإيصال والمتابعة"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "تسجيل تفاصيل المعاملات في مذكرة الطلب"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "رسوم التسجيل: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "إخفاء تفاصيل الوديعة"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "إظهار تفاصيل الوديعة"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "الأموال المتوافرة"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "تم الإلغاء"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "المقدَّر"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "فشل"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "قيد النقل"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "مدفوع"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "معلق"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "غير معروف"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "الوديعة الأخيرة"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "معرفة المزيد حول موعد الحصول على أموالك"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "الأموال المعلّقة"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "المستند على الجهاز"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "يتطلب منك الشحن إلى البلدان التي تتبع القواعد الجمركية المعمول بها في الاتحاد الأوروبي وصف كل عنصر الآن بوضوح. على سبيل المثال، إذا كنت ترسل ملابس، فيجب عليك تحديد نوع الملابس (مثل: قمصان الرجال وسترات النساء التحتية وسترات الأولاد) لكي يصبح الوصف مقبولاً. وإلا، فإن الشحنات قد تتأجل أو تُعتَرض في الجمارك."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "كل %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "كل يوم"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "كل شهر"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "كل شهر على %1$@"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "يلزم الوصول إلى الموقع لقبول المدفوعات."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "يستخدم Woo الميكروفون للسماح لك بالتقاط الصوت عند تسجيل الفيديوهات لمكتبة الوسائط في متجرك."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "لحفظ الصور من كاميرا خاصة بصور المنتجات أو لإضافة صور أو مقاطع فيديو إلى منتجاتك أو تذاكر الدعم الخاصة بك."; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "عملية استرداد الأموال اليدوية"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "يدويًا عند الطلب"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "قراء البطاقة"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "جارٍ تحميل الأرصدة..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 تم اكتمال الطلب"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "الإعدادات"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "جارٍ تحميل الأرصدة..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "نبذة عن ميزة Tap to Pay"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "تم"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "تم"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "الرصيد في Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "الرصيد في Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "الرصيد في Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "الرصيد في Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "شهر"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "إضافة الشحن"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "إلغاء"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "إدخال البريد الإلكتروني"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "البريد الإلكتروني"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "إرسال الإيصال عبر البريد الإلكتروني"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "خطأ في أثناء إرسال الإيصال عبر البريد الإلكتروني. يرجى المحاولة مجددًا."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "يرجى إدخال عنوان بريد إلكتروني صالح."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "إرسال الإيصال عبر البريد الإلكتروني إلى العميل"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "إضافة الشحن"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "المدفوعات، النقر للدفع، woocommerce، woo، المدفوعات الشخصية، الدفعات الشخصية، تحصيل الدفع، المدفوعات، القارئ، قارئ البطاقات، قارئ بطاقة الطلبات "; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "إخفاء تفاصيل الدفع"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "إظهار تفاصيل الدفع"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "الأموال المتوفرة"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "آخر المدفوعات"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "معرفة المزيد حول موعد الحصول على أموالك"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "تم الإلغاء"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "المقدَّر"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "فشل"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "قيد النقل"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "مدفوع"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "معلق"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "غير معروف"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "الأموال المعلّقة"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "انتهت الخطة"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "تجربة طريقة دفع أخرى"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "محاولة الدفع مجددًا"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "تحرير الطلب"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "خطأ في تحضير الدفع"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "محاولة الدفع مجددًا"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "يرجى الانتظار..."; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "طلب جديد"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "الإيصال"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "الإجمالي الفرعي"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "عذرًا، حدث خطأ ما. يرجى المحاولة مرة أخرى!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "إذا لم يكن لديك حساب، فإننا سنستخدم هذا البريد الإلكتروني لإنشاء واحد."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "لا يمكننا العثور على حساب ووردبريس.كوم متصلاً باسم المستخدم هذا. يمكنك إدخال بريد إلكتروني لإنشاء حساب جديد."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "إلغاء"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "تسجيل الدخول"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "تأكد من أن بريدك الإلكتروني صحيح وتحقق مجددًا من مجلد البريد المزعج."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/de.lproj/InfoPlist.strings b/WooCommerce/Resources/de.lproj/InfoPlist.strings index f5c174a7f32..d340450588e 100644 --- a/WooCommerce/Resources/de.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/de.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Um Fotos oder Videos aufzunehmen, damit du diese zu deinen Produkten hinzufügen kannst, um Barcodes für Produkt-SKUs zu scannen oder für Support-Tickets. NSLocationWhenInUseUsageDescription Standortzugriff ist erforderlich, um Zahlungen zu akzeptieren. + NSMicrophoneUsageDescription + Woo verwendet dein Mikrofon für die Audioaufnahme, wenn du Videos für die Mediathek deines Shops erstellst. NSPhotoLibraryUsageDescription Um Fotos aus der Kamera für Produktbilder zu speichern oder Fotos oder Videos zu deinen Produkten oder Support-Tickets hinzuzufügen. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/de.lproj/Localizable.strings b/WooCommerce/Resources/de.lproj/Localizable.strings index f58ad66bb5f..85c8483a5d4 100644 --- a/WooCommerce/Resources/de.lproj/Localizable.strings +++ b/WooCommerce/Resources/de.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-14 09:54:03+0000 */ +/* Translation-Revision-Date: 2024-11-27 09:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: de */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic-Familie"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Verfügbares Guthaben wird automatisch %1$@ ausgezahlt."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Verfügbares Guthaben wird automatisch %1$@ ausgezahlt."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Verfügbares Guthaben wird manuell, auf Anfrage ausgezahlt."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Verfügbares Guthaben wird auf Anfrage manuell ausgezahlt."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Durchschnittlicher Bestellwert"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Zollposition %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Individuelles Paket"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Fehler beim Aktualisieren der Bestellnummer%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Voraussichtlich am %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Vollständige Funktionsliste"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Guthaben, das seit %1$d Tagen aussteht, wird zur Verfügung gestellt."; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Bestand"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Myanmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "k. A."; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Der Beleg konnte nicht abgerufen werden."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Leider kannst du diese Bestellung nur im Web bearbeiten, da %1$@ verwendet wird und die Währung deiner Website %2$@ ist."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Alle %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "LIEFERADRESSE"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Abbrechen"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA-Text"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Call-to-Action"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Beschreibungstext für die Blaze-Werbung"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Werbung bearbeiten"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA-Text darf nicht länger als %1$d Zeichen sein"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "Beschreibung darf nicht leer sein"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Suche nach Kartenlesegerät"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Es wurde ein Beleg an %1$@ gesendet"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Beleg per E-Mail senden"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Zahlung erfolgreich"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Beleg drucken"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Es wurde ein Beleg an %1$@ gesendet"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Beleg speichern und fortfahren"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Transaktionsdetails in Bestellhinweis verzeichnen"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Registrierungsgebühr: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Auszahlungsdetails ausblenden"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Auszahlungsdetails anzeigen"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Verfügbares Guthaben"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Abgebrochen"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Geschätzt"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Fehlgeschlagen"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "Unterwegs"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Bezahlt"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Ausstehend"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Unbekannt"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Letzte Anzahlung"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Weitere Informationen zum Erhalt deiner Zahlungen"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Ausstehendes Guthaben"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Dokument auf Gerät"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Beim Versand in Länder, die den Zollvorschriften der Europäischen Union (EU) unterliegen, musst du jeden Artikel ab sofort klar und deutlich beschreiben. Beispiel: Wenn du Bekleidung versendest, musst du die Art der Bekleidung angeben (z. B. Shirts für Männer, Weste für Mädchen, Jacke für Jungen), damit die Beschreibung akzeptiert wird. Ansonsten kann es zu Verzögerungen oder Unterbrechungen von Lieferungen durch den Zoll kommen."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "jeden %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "jeden Tag"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "jeden Monat"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "am %1$@. jedes Monats"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Standortzugriff ist erforderlich, um Zahlungen zu akzeptieren."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo verwendet dein Mikrofon für die Audioaufnahme, wenn du Videos für die Mediathek deines Shops erstellst."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Um Fotos aus der Kamera für Produktbilder zu speichern oder Fotos oder Videos zu deinen Produkten oder Support-Tickets hinzuzufügen."; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "Manuelle Rückerstattung"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manuell, auf Anfrage"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Kartenlesegeräte"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Guthaben werden geladen …"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Bestellung fertiggestellt"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Einstellungen"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Guthaben werden geladen …"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Informationen zu Tap to Pay"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Fertig"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Fertig"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "WooPayments-Guthaben"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "WooPayments-Guthaben"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "WooPayments-Guthaben"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "WooPayments-Guthaben"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Monat"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Versand hinzufügen"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Abbrechen"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "E-Mail-Adresse eingeben"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-Mail-Adresse"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Beleg per E-Mail senden"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Beim Versenden des Belegs per E-Mail ist ein Fehler aufgetreten. Bitte versuche es noch einmal."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Bitte gib eine gültige E-Mail-Adresse ein."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Beleg per E-Mail an den Kunden senden"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Versand hinzufügen"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "zahlungen, tap to pay, woocommerce, woo, persönliche zahlungen, persönliche zahlungen, zahlung empfangen, zahlungen, reader, kartenlesegerät, kartenlesegerät bestellen"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Auszahlungsdetails ausblenden"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Auszahlungsdetails anzeigen"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Verfügbares Guthaben"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Letzte Auszahlung"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Weitere Informationen zum Erhalt deiner Zahlungen"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Abgebrochen"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Geschätzt"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Fehlgeschlagen"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "Unterwegs"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Bezahlt"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Ausstehend"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Unbekannt"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Ausstehendes Guthaben"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "Tarif beendet"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Andere Zahlungsmethode verwenden"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Zahlung erneut versuchen"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Bestellung bearbeiten"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Fehler bei der Zahlungsvorbereitung"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Zahlung erneut versuchen"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Bitte warten …"; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Neue Bestellung"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Beleg"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Zwischensumme"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Ups, etwas ist schiefgelaufen. Versuche es bitte noch einmal!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Wenn du noch kein Konto hast, benutzen wir diese E-Mail-Adresse, um ein neues zu erstellen."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Wir können kein WordPress.com-Konto finden, das mit diesem Benutzernamen verknüpft ist. Gib eine E-Mail-Adresse ein, um ein neues Konto zu erstellen."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Abbrechen"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Anmelden"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Stelle sicher, dass deine E-Mail-Adresse korrekt ist, und überprüfe deinen Spam-Ordner."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/en.lproj/Localizable.strings b/WooCommerce/Resources/en.lproj/Localizable.strings index 466b314df4c..27f1797a8e2 100644 --- a/WooCommerce/Resources/en.lproj/Localizable.strings +++ b/WooCommerce/Resources/en.lproj/Localizable.strings @@ -250,10 +250,6 @@ which should be translated separately and considered part of this sentence. */ /* Number of items in packages in Shipping Labels in plural form. Reads like - 10 items */ "- %1$d items" = "- %1$d items"; -/* Settings > Manage Card Reader > Connect > Help hint number 1 - Settings > Set up Tap to Pay on iPhone > Information > Help hint number 1 */ -"1" = "1"; - /* Message of the free trial banner when there is 1 day left */ "1 day left in your trial." = "1 day left in your trial."; @@ -270,20 +266,12 @@ which should be translated separately and considered part of this sentence. */ /* Step 1 of the 'How it works' list, instructing the merchant to prepare an order for payment */ "1. Create an order" = "1. Create an order"; -/* Settings > Manage Card Reader > Connect > Help hint number 2 - Settings > Set up Tap to Pay on iPhone > Information > Help hint number 2 */ -"2" = "2"; - /* Disclaimer regarding some of the features related to shipping. */ "2. Only available in the U.S. – an additional extension will be required for other countries." = "2. Only available in the U.S. – an additional extension will be required for other countries."; /* Step 2 of the 'How it works' list, instructing the merchant to how to select Tap to Pay payments */ "2. Tap “Collect Payment” and choose “Tap to Pay on iPhone”." = "2. Tap “Collect Payment” and choose “Tap to Pay on iPhone”."; -/* Settings > Manage Card Reader > Connect > Help hint number 3 - Settings > Set up Tap to Pay on iPhone > Information > Help hint number 3 */ -"3" = "3"; - /* Step 3 of the 'How it works' list, instructing the merchant to present the phone to the shopper */ "3. Present your iPhone to the customer." = "3. Present your iPhone to the customer."; @@ -1176,9 +1164,6 @@ which should be translated separately and considered part of this sentence. */ /* Title of the secondary button on the store onboarding launched store screen. */ "Back to My Store" = "Back to My Store"; -/* Button to dismiss modal overlay. Presented to users after collecting a payment fails */ -"Back to Order" = "Back to Order"; - /* Text for the button to dismiss the store picker error screen */ "Back to sites" = "Back to sites"; @@ -2371,9 +2356,48 @@ which should be translated separately and considered part of this sentence. */ /* Label for a dismiss button when a software update has finished */ "CardPresentModalUpdateProgress.button.dismissButtonText" = "Dismiss"; +/* A title for CTA to present native location permission alert */ +"cardPresentPayment.locationPreAlert.continueButton" = "Continue"; + +/* A notice at the bottom explaining that location services can be changed in the Settings app later */ +"cardPresentPayment.locationPreAlert.settingsNotice" = "You can change this option later in the Settings app."; + +/* A subtitle explaining why location services are needed to make a payment */ +"cardPresentPayment.locationPreAlert.subtitle" = "Location services permission is required to reduce fraud, prevent disputes, and ensure secure payments."; + +/* A title explaining why location services are needed to make a payment */ +"cardPresentPayment.locationPreAlert.title" = "Enable location services on the next screen to allow payments."; + +/* Dismisses the location alert */ +"cardPresentPayment.locationRequired.dismiss" = "Dismiss"; + +/* Opens iOS's Device Settings for the app */ +"cardPresentPayment.locationRequired.openSettings" = "Open Device Settings"; + +/* A subtitle explaining why location services are needed to make a payment */ +"cardPresentPayment.locationRequired.subtitle" = "Location services permission is required to reduce fraud, prevent disputes, and ensure secure payments."; + +/* A title explaining the requirement of location services for making a payment */ +"cardPresentPayment.locationRequired.title" = "Enable location services in device settings to allow payments."; + +/* Button to dismiss modal overlay. Presented to users after collecting a payment fails */ +"cardPresentPaymentsModal.error.backToOrder" = "Back to Order"; + +/* Button to dismiss modal overlay. Presented to users after refunding a payment fails */ +"cardPresentPaymentsModal.error.close" = "Close"; + +/* Button to dismiss. Presented to users after collecting a payment fails */ +"cardPresentPaymentsModal.error.dismiss" = "Dismiss"; + +/* Button to email receipts. Presented to users after a payment processing has failed */ +"cardPresentPaymentsModal.error.emailReceipt" = "Email receipt"; + /* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ "cardPresentPaymentsModal.error.receiptMessage" = "A receipt has been sent to %1$@"; +/* Button to dismiss modal overlay and try another payment method. Presented to users after collecting a payment fails */ +"cardPresentPaymentsModal.error.tryAnotherPaymentMethod" = "Try Another Payment Method"; + /* Button to email receipts. Presented to users after a payment has been successfully collected */ "cardPresentPaymentsModal.success.emailReceipt" = "Email receipt"; @@ -2582,7 +2606,6 @@ which should be translated separately and considered part of this sentence. */ "Clear selection" = "Clear selection"; /* Accessibility label for the close button - Button to dismiss modal overlay. Presented to users after refunding a payment fails Button to dismiss the alert presented when starting Tap to Pay on iPhone fails. This also cancels searching. Button to dismiss the Coupon Creation Success screen Navigation bar action to close the gift card code scanner. @@ -3760,7 +3783,6 @@ which should be translated separately and considered part of this sentence. */ Button to dismiss the alert presented when finding a reader to connect to fails Button to dismiss the first created product screen Button to dismiss the Jetpack benefits screen. - Button to dismiss. Presented to users after collecting a payment fails Button to dismiss. Presented to users when updating the card reader software fails Dismiss button in inbox note row. Dismiss button in store picker @@ -4058,6 +4080,27 @@ which should be translated separately and considered part of this sentence. */ /* The value of custom field to be edited. */ "editorFactory.customFieldsValueTitle" = "Value"; +/* Button to dismiss the Edit Store List view */ +"editStoreListView.cancelButton" = "Cancel"; + +/* Footer of the Current Store section of the the Edit Store List view */ +"editStoreListView.currentStoreFooter" = "Please switch to another store before hiding this store"; + +/* Header of the Current Store section of the the Edit Store List view */ +"editStoreListView.currentStoreHeader" = "Current store"; + +/* Footer of the Other Stores section on the Edit Store List view */ +"editStoreListView.otherStoresFooter" = "Stores that are not selected will be excluded from the store picker"; + +/* Header of the Other Stores section on the Edit Store List view */ +"editStoreListView.otherStoresHeader" = "Other stores"; + +/* Button to save changes in the Edit Store List view */ +"editStoreListView.saveButton" = "Save"; + +/* Title of the Edit Store List view */ +"editStoreListView.title" = "Visible Stores"; + /* Country option for a site address. */ "Egypt" = "Egypt"; @@ -4963,6 +5006,132 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Haiti" = "Haiti"; +/* Explanation in the alert presented when the user tries to connect a Bluetooth card reader with insufficient permissions */ +"hardware.cardReader.cardReaderServiceError.bluetoothDenied" = "This app needs permission to access Bluetooth to connect to your card reader. You can grant permission in the system's Settings app, in the Woo section."; + +/* Error message when the Apple built-in reader account is deactivated. */ +"hardware.cardReader.underlyingError.appleBuiltInReaderAccountDeactivated" = "The linked Apple ID account has been deactivated."; + +/* Error message when Bluetooth is already paired with another device. */ +"hardware.cardReader.underlyingError.bluetoothAlreadyPairedWithAnotherDevice" = "The Bluetooth reader is already paired to another device. The reader must have its pairing reset to connect to this device."; + +/* Error message when the Bluetooth connection has an invalid location ID parameter. */ +"hardware.cardReader.underlyingError.bluetoothConnectionInvalidLocationIdParameter" = "The Bluetooth connection has an invalid location ID."; + +/* Explanation in the alert presented when the user tries to connect a Bluetooth card reader with insufficient permissions */ +"hardware.cardReader.underlyingError.bluetoothDenied" = "This app needs permission to access Bluetooth to connect to your card reader. You can grant permission in the system's Settings app, in the Woo section."; + +/* Error message when the Bluetooth peer removed pairing information. */ +"hardware.cardReader.underlyingError.bluetoothPeerRemovedPairingInformation" = "The reader has removed this device pairing information. Try forgetting the reader in iOS Settings."; + +/* Error message when Bluetooth reconnect has started. */ +"hardware.cardReader.underlyingError.bluetoothReconnectStarted" = "The Bluetooth reader has disconnected and we are attempting to reconnect."; + +/* Error message when an operation cannot be canceled because it is already completed. */ +"hardware.cardReader.underlyingError.cancelFailedAlreadyCompleted" = "The operation could not be canceled because it was already completed."; + +/* Error message when card swipe functionality is unavailable. */ +"hardware.cardReader.underlyingError.cardSwipeNotAvailable" = "Card swipe functionality is unavailable."; + +/* Error message when the command is not allowed. */ +"hardware.cardReader.underlyingError.commandNotAllowed" = "Please contact support - the command is not allowed to execute by the operating system."; + +/* Error message when the command requires cardholder consent. */ +"hardware.cardReader.underlyingError.commandRequiresCardholderConsent" = "The cardholder must give consent in order for this operation to succeed."; + +/* Error message when the connection token provider finishes with an error. */ +"hardware.cardReader.underlyingError.connectionTokenProviderCompletedWithError" = "There was an error fetching the connection token."; + +/* Error message when the connection token provider operation times out. */ +"hardware.cardReader.underlyingError.connectionTokenProviderTimedOut" = "The connection token request timed out."; + +/* Error message when a feature is unavailable. */ +"hardware.cardReader.underlyingError.featureNotAvailable" = "Please contact support - the feature is unavailable."; + +/* Error message when forwarding a live mode payment in test mode is prohibited. */ +"hardware.cardReader.underlyingError.forwardingLiveModePaymentInTestMode" = "Forwarding a live mode payment in test mode is prohibited."; + +/* Error message when forwarding a test mode payment in live mode is prohibited. */ +"hardware.cardReader.underlyingError.forwardingTestModePaymentInLiveMode" = "Forwarding a test mode payment in live mode is prohibited."; + +/* Error message when Interac is not supported in offline mode. */ +"hardware.cardReader.underlyingError.interacNotSupportedOffline" = "Interac is not supported in offline mode."; + +/* Error message when an internal network error occurs. */ +"hardware.cardReader.underlyingError.internalNetworkError" = "An unknown network error occurred."; + +/* Error message when the internet connection operation timed out. */ +"hardware.cardReader.underlyingError.internetConnectTimeOut" = "Connecting to reader over the internet timed out. Make sure your device and reader are on the same Wifi network and your reader is connected to the Wifi network."; + +/* Error message when the client secret is invalid. */ +"hardware.cardReader.underlyingError.invalidClientSecret" = "Please contact support - the client secret is invalid."; + +/* Error message when the discovery configuration is invalid. */ +"hardware.cardReader.underlyingError.invalidDiscoveryConfiguration" = "Please contact support - the discovery configuration is invalid."; + +/* Error message when the location ID parameter is invalid. */ +"hardware.cardReader.underlyingError.invalidLocationIdParameter" = "The location ID is invalid."; + +/* Error message when the reader for update is invalid. */ +"hardware.cardReader.underlyingError.invalidReaderForUpdate" = "The reader for update is invalid."; + +/* Error message when the refund parameters are invalid. */ +"hardware.cardReader.underlyingError.invalidRefundParameters" = "Please contact support - the refund parameters are invalid."; + +/* Error message when a required parameter is invalid. */ +"hardware.cardReader.underlyingError.invalidRequiredParameter" = "Please contact support - a required parameter is invalid."; + +/* Error message when EMV data is missing. */ +"hardware.cardReader.underlyingError.missingEMVData" = "The reader failed to read the data from the presented payment method. If you encounter this error repeatedly, the reader may be faulty and please contact support."; + +/* Error message when the payment intent is missing. */ +"hardware.cardReader.underlyingError.nilPaymentIntent" = "Please contact support - the payment intent is missing."; + +/* Error message when the refund payment method is missing. */ +"hardware.cardReader.underlyingError.nilRefundPaymentMethod" = "Please contact support - the refund payment method is missing."; + +/* Error message when the setup intent is missing. */ +"hardware.cardReader.underlyingError.nilSetupIntent" = "Please contact support - the setup intent is missing."; + +/* Error message when the card is expired and offline mode is active. */ +"hardware.cardReader.underlyingError.offlineAndCardExpired" = "Confirming a payment while offline and the card was identified as being expired."; + +/* Error message when there is a mismatch between offline collect and confirm. */ +"hardware.cardReader.underlyingError.offlineCollectAndConfirmMismatch" = "Please ensure the network connection is consistent at payment collection and confirmation."; + +/* Error message when a test card is used in live mode while offline. */ +"hardware.cardReader.underlyingError.offlineTestCardInLivemode" = "A test card is used in live mode while offline."; + +/* Error message when the offline transaction was declined. */ +"hardware.cardReader.underlyingError.offlineTransactionDeclined" = "Confirming a payment while offline and the card’s verification failed."; + +/* Error message when online PIN is not supported in offline mode. */ +"hardware.cardReader.underlyingError.onlinePinNotSupportedOffline" = "Online PIN is not supported in offline mode. Please retry the payment with another card."; + +/* Error message when the reader connection configuration is invalid. */ +"hardware.cardReader.underlyingError.readerConnectionConfigurationInvalid" = "The reader connection configuration is invalid."; + +/* Error message when the reader is missing encryption keys. */ +"hardware.cardReader.underlyingError.readerMissingEncryptionKeys" = "The reader is missing encryption keys required for taking payments and has disconnected and rebooted. Reconnect to the reader to attempt to re-install the keys. If the error persists, please contact support."; + +/* Error message when the reader software update fails due to an expired update. */ +"hardware.cardReader.underlyingError.readerSoftwareUpdateFailedExpiredUpdate" = "Updating the reader software failed because the update has expired. Please disconnect and reconnect from the reader to retrieve a new update."; + +/* Error message when the reader tipping parameter is invalid. */ +"hardware.cardReader.underlyingError.readerTippingParameterInvalid" = "Please contact support - the reader tipping parameter is invalid."; + +/* Error message when the refund operation failed. */ +"hardware.cardReader.underlyingError.refundFailed" = "The refund failed. The customer’s bank or card issuer was unable to process it correctly (e.g., a closed bank account or a problem with the card)."; + +/* Error message when there is an error decoding the Stripe API response. */ +"hardware.cardReader.underlyingError.stripeAPIResponseDecodingError" = "Please contact support - there was an error decoding the Stripe API response."; + +/* Error message when an unexpected error occurs with the reader. */ +"hardware.cardReader.underlyingError.unexpectedReaderError" = "An unexpected error occurred with the reader."; + +/* Error message when the reader's IP address is unknown. */ +"hardware.cardReader.underlyingError.unknownReaderIpAddress" = "The reader returned from discovery does not have an IP address and cannot be connected to."; + /* Subtitle of the store onboarding task to customize the store domain. */ "Have a custom URL to host your store." = "Have a custom URL to host your store."; @@ -6121,9 +6290,6 @@ which should be translated separately and considered part of this sentence. */ /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "About Tap to Pay"; -/* Navigates to a screen to share feedback about Tap to Pay on iPhone. */ -"menu.payments.tapToPay.feedback.row.title" = "Share Feedback"; - /* Title for the Tap to Pay section in the In-Person payments settings */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; @@ -7914,6 +8080,21 @@ which should be translated separately and considered part of this sentence. */ /* Title of the error when failing to synchronize order and calculate order totals */ "pointOfSale.orderSync.error.title" = "Couldn't load totals"; +/* Button title for sending a receipt */ +"pointOfSale.sendreceipt.button.title" = "Send"; + +/* Text that shows at the top of the receipts screen along the back button. */ +"pointOfSale.sendreceipt.emailReceiptNavigationText" = "Email receipt"; + +/* Error message that is displayed when an invalid email is used when emailing a receipt. */ +"pointOfSale.sendreceipt.emailValidationErrorText" = "Please enter a valid email."; + +/* Generic error message that is displayed when there's an error emailing a receipt. */ +"pointOfSale.sendreceipt.sendReceiptErrorText" = "Error trying to send this email. Try again."; + +/* Placeholder for the view where an email address should be entered when sending receipts */ +"pointOfSale.sendreceipt.textfield.placeholder" = "Type email"; + /* Button to dismiss the payments onboarding sheet from the POS dashboard. */ "pointOfSaleDashboard.payments.onboarding.cancel" = "Cancel"; @@ -8009,10 +8190,13 @@ which should be translated separately and considered part of this sentence. */ "pos.simpleProductsModal.title" = "Why can't I see my products?"; /* Button title for new order button */ -"pos.totalsView.newOrder" = "New order"; +"pos.totalsView.button.newOrder" = "New order"; /* Button title for the receipt button */ -"pos.totalsView.sendReceipt" = "Receipt"; +"pos.totalsView.button.sendReceipt" = "Email receipt"; + +/* Text for the banner requiring specific WooCommerce version. */ +"pos.totalsView.receipts.banner.updateWooCommerceVersionText" = "Please update WooCommerce to version 9.5.0"; /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotal"; @@ -9020,9 +9204,6 @@ which should be translated separately and considered part of this sentence. */ /* Title of the secondary button displayed when activating the purchased plan fails, so the merchant can exit the flow. */ "Return to My Store" = "Return to My Store"; -/* Settings > Set up Tap to Pay on iPhone > Try a Payment > A button to skip to the trial payment and dismiss the Set up Tap to Pay on iPhone flow */ -"Return to Payments" = "Return to Payments"; - /* Title for the return policy in Customs screen of Shipping Label flow */ "Return to sender if package is unable to be delivered" = "Return to sender if package is unable to be delivered"; @@ -9442,6 +9623,9 @@ which should be translated separately and considered part of this sentence. */ /* Title of the alert presented when the user tries to start Tap to Pay on iPhone and it fails */ "Setup failed" = "Setup failed"; +/* Settings > Set up Tap to Pay on iPhone > Try a Payment > A button to skip to the trial payment and dismiss the Set up Tap to Pay on iPhone flow */ +"SetUpTapToPayPaymentPrompt.skipButton.title" = "Not now"; + /* Settings > Set up Tap to Pay on iPhone > Try a Payment > After trying a payments, the merchant is shown an order confirmation screen, showing their payment was successful and allowing them to refund themselves. This is the navigation bar title for that screen. */ "setUpTapToPayPaymentPromptView.paymentComplete.navigation.title" = "Payment Complete"; @@ -9567,6 +9751,18 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button title for the Shipping Label purchase flow, shown in the nav bar */ "shipping.label.navigationBar.cancel.button.title" = "Cancel"; +/* Badge wording when the customs information is completed */ +"shippingLabels.customs.completedBadge" = "Completed"; + +/* Customs row title in the main Shipping Labels view */ +"shippingLabels.customs.customsTitle" = "Customs"; + +/* Accessibility label for the button to edit the shipping labels customs */ +"shippingLabels.customs.editButtonAccessibiliy" = "Edit Shipping Labels Customs Info"; + +/* Badge wording when the customs information is missing */ +"shippingLabels.customs.missingInfo" = "Missing info"; + /* Accessibility title for the edit button on a shipping line row. */ "shippingLine.edit.button.accessibilityLabel" = "Edit shipping"; @@ -9977,6 +10173,9 @@ which should be translated separately and considered part of this sentence. */ /* Button in date range picker to add a Custom Range tab */ "storePerformanceViewModel.addCustomRange" = "Add"; +/* Button to edit the items to be displayed on the store picker */ +"storePicker.editList" = "Edit"; + /* Button title on the store picker for store connection */ "storePickerViewController.addStoreButton" = "Connect existing store"; @@ -10368,6 +10567,84 @@ which should be translated separately and considered part of this sentence. */ /* After a trial Tap to Pay payment, we attempt to automatically refund the test amount. When this is successful, we show a Notice to alert the user – this is the title of the notice. */ "tap.to.pay.try.payment.refundNotice.success.title" = "Tap to Pay Trial Payment"; +/* Title of the button to dismiss the Tap to Pay awareness screen */ +"tapToPay.awarenessMoment.closeButton" = "Close"; + +/* A title for CTA to start Tap to Pay set up process */ +"tapToPay.awarenessMoment.enableButton" = "Enable now"; + +/* A title for CTA to open a view explaining Tap to Pay */ +"tapToPay.awarenessMoment.learnMore" = "Learn more"; + +/* A subtitle for the Tap to Pay modal promoting the feature. */ +"tapToPay.awarenessMoment.subtitle" = "Accept contactless payments with only an iPhone."; + +/* Title for the Tap to Pay modal promoting the feature. When using the name “Tap to Pay on iPhone” in headlines or copy, do not shorten to “Tap to Pay” or “Apple Tap to Pay. Always typeset “Tap to Pay on iPhone” as five words. The T and Ps should be uppercased, followed by lowercase letters. */ +"tapToPay.awarenessMoment.title" = "Tap to Pay on iPhone. Now available."; + +/* Text for the button to take one step back in Tap to Pay education flow */ +"tapToPay.education.back" = "Back"; + +/* Text for the button to dismiss Tap to Pay education flow */ +"tapToPay.education.done" = "Done"; + +/* Text for the button to go to the next Tap to Pay education flow step */ +"tapToPay.education.next" = "Next"; + +/* Button title for Set up Tap to Pay button in Tap to Pay education flow. The button opens the Set Up Tap to Pay on iPhone flow. */ +"tapToPay.education.setUpTapToPay" = "Set Up Tap to Pay on iPhone"; + +/* Text for the button to skip Tap to Pay education flow to the last step */ +"tapToPay.education.skip" = "Skip"; + +/* First description step for the 'How to accept Apple Pay' Tap to Pay merchant education */ +"tapToPay.education.step.applePay.descriptionStep1" = "Create an order on your iPhone, add products or a custom amount, and check out with Tap to Pay on iPhone."; + +/* Second description step for the 'How to accept Apple Pay' Tap to Pay merchant education */ +"tapToPay.education.step.applePay.descriptionStep2" = "Present your iPhone to the customer."; + +/* Third description step for the 'How to accept Apple Pay' Tap to Pay merchant education */ +"tapToPay.education.step.applePay.descriptionStep3" = "Your customer holds their device near your iPhone, over the contactless symbol."; + +/* Fourth description step for the 'How to accept Apple Pay' Tap to Pay merchant education */ +"tapToPay.education.step.applePay.descriptionStep4" = "When you see the Done checkmark, the card read is complete and the transaction is being processed."; + +/* Title for the 'How to accept Apple Pay' Tap to Pay merchant education */ +"tapToPay.education.step.applePay.title" = "How to accept Apple Pay and other digital wallets with Tap to Pay on iPhone."; + +/* First description step for the 'How to accept contactless card' Tap to Pay merchant education */ +"tapToPay.education.step.contactlessCard.descriptionStep1" = "Create an order on your iPhone, add products or a custom amount, and check out with Tap to Pay on iPhone."; + +/* Second description step for the 'How to accept contactless card' Tap to Pay merchant education */ +"tapToPay.education.step.contactlessCard.descriptionStep2" = "Present your iPhone to the customer."; + +/* Third description step for the 'How to accept contactless card' Tap to Pay merchant education */ +"tapToPay.education.step.contactlessCard.descriptionStep3" = "Your customer holds their card horizontally at the top of your iPhone, over the contactless symbol."; + +/* Fourth description step for the 'How to accept contactless card' Tap to Pay merchant education */ +"tapToPay.education.step.contactlessCard.descriptionStep4" = "When you see the Done checkmark, the card read is complete and the transaction is being processed."; + +/* Title for the 'How to accept contactless card' Tap to Pay merchant education */ +"tapToPay.education.step.contactlessCard.title" = "How to accept contactless card with Tap to Pay on iPhone."; + +/* Message displayed when a contactless transaction fails due to PIN issues. Provides steps to retry using another contactless payment method or alternative payment options. */ +"tapToPay.education.step.fallbackMethod.description" = "Some cards are not able to complete contactless transactions using a PIN, which can result in payment failure.\n\nAsk the customer if they have another contactless card or digital wallet and select Try Collecting Again to continue the transaction using Tap to Pay on iPhone.\n\nOtherwise, select Try Another Payment Method and choose a supported alternative, such as Cash, Share Payment Link, Cash Reader, or Scan to Pay."; + +/* Title for the 'Fallback payment method' Tap to Pay merchant education step */ +"tapToPay.education.step.fallbackMethod.title" = "How to accept an alternative payment method."; + +/* Description for the initial Tap to Pay merchant education step. When using the name “Tap to Pay on iPhone” in headlines or copy, do not shorten to “Tap to Pay” or “Apple Tap to Pay. Always typeset “Tap to Pay on iPhone” as five words. The T and Ps should be uppercased, followed by lowercase letters. */ +"tapToPay.education.step.intro.description" = "With Tap to Pay on iPhone and the Woo app, you can accept in-person, contactless payments, right on your iPhone - from physical debit and credit cards, to Apple Pay and other digital wallets - no extra hardware needed. It’s easy, secure, and private."; + +/* Title for the initial Tap to Pay merchant education step */ +"tapToPay.education.step.intro.title" = "Accept contactless payments with only an iPhone."; + +/* Instructions for customers using accessibility options during PIN entry with Tap to Pay on iPhone.Describes how to use audible guidance for drawing or tapping the PIN digits and the gesture to submit. */ +"tapToPay.education.step.pin.description" = "Customers are prompted to enter their card PIN under specific circumstances with Tap to Pay on iPhone.\n\nFor customers needing visual or other assistance, accessibility options are accessed by selecting ‘Accessibility Options’ on the PIN screen. Audible instructions guide customers to draw their PIN on the screen or tap the screen to indicate each digit - tapping once for 1, twice for 2, and so on. To submit their PIN, they simply swipe right with two fingers."; + +/* Title for the 'PIN entry' Tap to Pay merchant education step */ +"tapToPay.education.step.pin.title" = "How to handle PIN entry for a card."; + /* A label prompting users to learn more about Tap to Pay on iPhone. %1$@ is a placeholder that always replaced with \"Learn more\" string, which should be translated separately and considered part of this sentence. */ @@ -10880,9 +11157,6 @@ which should be translated separately and considered part of this sentence. */ Title of button that displays information about the third party software libraries used in the creation of this app */ "Third Party Licenses" = "Third Party Licenses"; -/* Explanation in the alert presented when the user tries to connect a Bluetooth card reader with insufficient permissions */ -"This app needs permission to access Bluetooth to connect to a card reader, please change the privacy settings if you wish to allow this." = "This app needs permission to access Bluetooth to connect to a card reader, please change the privacy settings if you wish to allow this."; - /* Body text of alert warning users to upgrade to WC 3.5. */ "This app requires that you install WooCommerce 3.5 on your server, and won't work properly without it. Update as soon as possible to continue using this app." = "This app requires that you install WooCommerce 3.5 on your server, and won't work properly without it. Update as soon as possible to continue using this app."; @@ -12318,12 +12592,12 @@ which should be translated separately and considered part of this sentence. */ /* Label for row showing the additional cost to require an adult signature on the shipping label creation screen */ "wooShipping.createLabels.bottomSheet.adultSignatureRequired" = "Adult Signature Required"; +/* Label for the toggle to mark the order as complete on the shipping label creation screen */ +"wooShipping.createLabels.bottomSheet.afterPurchaseMarkComplete" = "After purchasing a label, mark this order as complete and notify the customer"; + /* Label for row showing the base fee for the selected shipping service on the shipping label creation screen. Reads like: 'USPS - Media Mail (base fee)' */ "wooShipping.createLabels.bottomSheet.baseFee" = "%1$@ (base fee)"; -/* Label for the toggle to mark the order as complete on the shipping label creation screen */ -"wooShipping.createLabels.bottomSheet.markComplete" = "Mark this order complete and notify the customer"; - /* Header for order details section on the shipping label creation screen */ "wooShipping.createLabels.bottomSheet.orderDetails" = "Order details"; @@ -12366,6 +12640,12 @@ which should be translated separately and considered part of this sentence. */ /* Length, width, and height dimensions with the unit for an item to ship. Reads like: '20 x 35 x 5 cm' */ "wooShipping.createLabels.items.dimensions" = "%1$@ x %2$@ x %3$@ %4$@"; +/* Heading for the package section in the shipping label creation screen. */ +"wooShipping.createLabels.package.title" = "Package"; + +/* Label for the total shipment weight input field in the shipping label creation screen. */ +"wooShipping.createLabels.package.totalWeight" = "Total shipment weight (with package)"; + /* Link for more information about how to print a purchased shipping label on the shipping label screen */ "wooShipping.createLabels.postPurchase.info" = "Learn how to print from your mobile device"; diff --git a/WooCommerce/Resources/es.lproj/InfoPlist.strings b/WooCommerce/Resources/es.lproj/InfoPlist.strings index 0b99eb483eb..6a45cc8122e 100644 --- a/WooCommerce/Resources/es.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/es.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Para tomar fotos o videos para añadir a tus productos, escanear el código de barras para el SKU del producto, o tiques de soporte. NSLocationWhenInUseUsageDescription Se requiere acceso a la ubicación para aceptar pagos. + NSMicrophoneUsageDescription + Woo usa tu micrófono para que puedas grabar audio a la vez que grabas vídeos para la biblioteca de medios de tu tienda. NSPhotoLibraryUsageDescription Para guardar fotos de la cámara para las imágenes de los productos, o para añadir fotos o videos a tus productos o tiques de soporte. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/es.lproj/Localizable.strings b/WooCommerce/Resources/es.lproj/Localizable.strings index c12a6cef865..57bff6f3ce4 100644 --- a/WooCommerce/Resources/es.lproj/Localizable.strings +++ b/WooCommerce/Resources/es.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-06 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-27 09:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: es */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Familia Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Los fondos disponibles se depositan automáticamente, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Los fondos disponibles se pagan automáticamente %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Los fondos disponibles se depositan manualmente, previa solicitud."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Los fondos disponibles se pagan manualmente, previa solicitud."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Valor medio de los pedidos"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Línea personalizada %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Paquete personalizado"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Error al actualizar el pedido n.º %1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Est. %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Lista completa de las funciones"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Los fondos estarán disponibles después de estar pendientes durante %1$d días."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabón"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Formato del número de transacción internacional no válido"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Identificador no válido"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Nombre del paquete no válido"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Inventario"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Birmania"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "n\/d"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "No se ha podido recuperar el recibo."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Solo puedes editar este pedido en la web, ya que utiliza %1$@ y la moneda de tu sitio es %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Cada %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Tipo de producto"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "El producto con el identificador «%@» no se puede comprar."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "No se ha encontrado ningún producto con el identificador «%@»."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Escanear código de barras de producto o código QR"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "DIRECCIÓN DE ENVÍO"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Cancelar"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Texto de la llamada a la acción"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Llamada a la acción"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Texto de descripción para el anuncio de Blaze"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Editar anuncio"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "El texto de la llamada a la acción no puede tener más de %1$d caracteres."; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "La descripción no puede estar vacía."; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Buscando lector"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Se ha enviado un recibo a %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Enviar recibo por correo electrónico"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Pago correcto"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Imprimir recibo"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Se ha enviado un recibo a %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Guardar recibo y continuar"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Registrar detalles de la transacción en una nota del pedido"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Tarifa de inscripción: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Ocultar detalles del depósito"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Mostrar detalles del depósito"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Fondos disponibles"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Cancelado"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Estimado"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Fallido"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "En tránsito"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Pagado"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Pendiente"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Desconocido"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Último depósito"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Obtén más información acerca de cuándo recibirás tus fondos."; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Fondos pendientes"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Documento en el dispositivo"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Ahora, para hacer envíos a países en los que se aplica la normativa aduanera de la Unión Europea (UE), debes describir cada artículo con claridad. Por ejemplo, si envías ropa, para que se acepte la descripción, debes indicar el tipo de ropa del que se trata (camisas de hombre, chalecos para chicas, chaquetas para chicos, etc.). De lo contrario, los envíos pueden sufrir retrasos o interrupciones en la aduana."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "todos los %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "todos los días"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "todos los meses"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "todos los meses en el %1$@"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Se requiere acceso a la ubicación para aceptar pagos."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo usa tu micrófono para que puedas grabar audio a la vez que grabas vídeos para la biblioteca de medios de tu tienda."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Para guardar fotos de la cámara para las imágenes de los productos, o para añadir fotos o videos a tus productos o tiques de soporte."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "reembolso manual"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manualmente, previa solicitud"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Lectores de tarjetas"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Cargando saldo..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Pedido completado"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Ajustes"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Cargando saldo..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Acerca de Tap to Pay"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Hecho"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Hecho"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Saldo de pagos de Woo"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Saldo de pagos de Woo"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Saldo de pagos de Woo"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Saldo de pagos de Woo"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Mes"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Añadir envío"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Cancelar"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Introducir correo electrónico"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "Correo electrónico"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Enviar recibo por correo electrónico"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Error al enviar el recibo por correo electrónico. Inténtalo de nuevo."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Introduce una dirección de correo electrónico válida."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Enviar recibo al cliente por correo electrónico"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Añadir envío"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "pagos, toca para pagar, woocommerce, woo, pagos en persona, recibir pago, pagos, lector, lector de tarjetas, pedir lector de tarjetas"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Ocultar detalles del pago"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Mostrar detalles del pago"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Fondos disponibles"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Último pago"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Obtén más información acerca de cuándo recibirás tus fondos."; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Cancelado"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Estimado"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Fallido"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "En tránsito"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Pagado"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Pendiente"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Desconocido"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Fondos pendientes"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "plan finalizado"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Prueba otro método de pago"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Intentar efectuar el pago de nuevo"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Editar pedido"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Error en la preparación del pago"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Intentar efectuar el pago de nuevo"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Espera…"; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Nuevo pedido"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Recibo"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotal"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Vaya, se ha producido un error. Inténtalo de nuevo."; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Si no tienes una cuenta, usaremos este correo electrónico para crear una."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "No encontramos una cuenta de WordPress.com asociada a este nombre de usuario. Puedes introducir un correo electrónico para crear una cuenta."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Cancelar"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Acceder"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Comprueba que tu correo electrónico está bien escrito y vuelve a mirar en tu carpeta de spam."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/fr.lproj/InfoPlist.strings b/WooCommerce/Resources/fr.lproj/InfoPlist.strings index 2865286316c..530a0de6034 100644 --- a/WooCommerce/Resources/fr.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/fr.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Pour prendre des photos ou des vidéos pour les ajouter à vos produits ou tickets d’assistance, ou pour scanner un code-barre pour obtenir la référence du produit. NSLocationWhenInUseUsageDescription L’accès à la localisation est requis pour accepter les paiements. + NSMicrophoneUsageDescription + Woo utilise votre micro pour vous permettre de capter l’audio lors de l’enregistrement de vidéos pour la médiathèque de votre boutique. NSPhotoLibraryUsageDescription Pour enregistrer des photos de l’appareil photo comme images de produits, ou pour ajouter des photos ou des vidéos à vos produits ou tickets d’assistance. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/fr.lproj/Localizable.strings b/WooCommerce/Resources/fr.lproj/Localizable.strings index 1db7705da05..0ca9b37ce42 100644 --- a/WooCommerce/Resources/fr.lproj/Localizable.strings +++ b/WooCommerce/Resources/fr.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-13 15:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-27 11:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n > 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: fr */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Famille Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Les fonds disponibles sont versés automatiquement, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Les fonds disponibles sont versés automatiquement, %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Les fonds disponibles sont versés manuellement, sur demande."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Les fonds disponibles sont versés manuellement, sur demande."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Valeur moyenne des commandes"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Ligne personnalisée %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Colis personnalisé"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Erreur lors de la mise à jour de la commande n°%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Date estimée le %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Liste intégrale des fonctionnalités"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Les fonds sont disponibles après avoir passé %1$d jours en attente."; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Stock"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Birmanie"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N\/A"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Impossible de récupérer le reçu."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Toutes nos excuses, vous pouvez modifier cette commande sur le Web uniquement car elle est en %1$@ et votre site est en %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Tous les %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "ADRESSE D’EXPÉDITION"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "UGS"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Annuler"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Texte de l’appel à l’action"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Appel à l’action"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Texte descriptif de la publicité Blaze"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Modifier la publicité"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "Le texte d’appel à l’action ne peut pas faire plus de %1$d caractères"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "La description ne peut pas être vide"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Recherche du lecteur"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Un reçu a été envoyé à %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Reçu par e-mail"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Paiement réussi"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Imprimer le reçu"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Un reçu a été envoyé à %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Enregistrer le reçu et continuer"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Enregistrer les détails de la transaction dans la note de commande"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Frais d’inscription : %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Masquer les détails du dépôt"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Afficher les détails du dépôt"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Fonds disponibles"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Annulé"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Estimé"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Échec"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "En transit"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Payé"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "En attente"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Inconnu"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Dernier dépôt"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "En savoir plus sur le moment où vous recevrez vos fonds"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Fonds en attente"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Document sur l’appareil"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "L’expédition vers des pays qui suivent la réglementation douanière de l’Union européenne (UE) nécessite désormais que vous décriviez clairement chaque article. Par exemple, si vous envoyez des vêtements, vous devez indiquer le type de vêtement (p. ex., chemise pour homme, débardeur pour fille, veste pour garçon) pour que la description soit acceptable. Dans le cas contraire, les envois peuvent être retardés ou interrompus à la douane."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "tous les %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "tous les jours"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "tous les mois"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "tous les mois le %1$@"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "L’accès à la localisation est requis pour accepter les paiements."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo utilise votre micro pour vous permettre de capter l’audio lors de l’enregistrement de vidéos pour la médiathèque de votre boutique."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Pour enregistrer des photos de l’appareil photo comme images de produits, ou pour ajouter des photos ou des vidéos à vos produits ou tickets d’assistance."; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "remboursement manuel"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manuellement, sur demande"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Lecteurs de carte"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Chargement des soldes…"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Commande terminée"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Réglages"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Chargement des soldes…"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "À propos d’Appuyer pour payer"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Appuyer pour payer"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Terminé"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Terminé"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Solde Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Solde Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Solde Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Solde Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Mois"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Ajouter l’expédition"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Annuler"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Saisir l’adresse e-mail"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-mail"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Reçu par e-mail"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Erreur lors de l’envoi du reçu par e-mail. Veuillez réessayer."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Veuillez saisir une adresse e-mail valide."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Envoyer le reçu par e-mail au client"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Ajouter l’expédition"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "paiements, appuyer pour payer, woocommerce, woo, paiements en personne, paiements en personne, percevoir un paiement, paiements, lecteur, lecteur de carte, commander un lecteur de carte"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Masquer les détails du paiement"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Afficher les détails du paiement"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Fonds disponibles"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Dernier paiement"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "En savoir plus sur le moment où vous recevrez vos fonds"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Annulé"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Estimé"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Échec"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "En transit"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Payé"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "En attente"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Inconnu"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Fonds en attente"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "plan ayant expiré"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Essayer un autre moyen de paiement"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Réessayer d’effectuer le paiement"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Modifier la commande"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Erreur de préparation du paiement"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Réessayer d’effectuer le paiement"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Veuillez patienter…"; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Nouvelle commande"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Reçu"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Sous-total"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Oups, une erreur s’est produite. Veuillez réessayer !"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Si vous n’avez pas de compte, nous utiliserons cette adresse e-mail pour en créer un."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Nous n’avons pas trouvé de compte WordPress.com relié à cet identifiant. Vous pouvez saisir une adresse e-mail pour créer un nouveau compte."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Annuler"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Se connecter"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Assurez-vous que votre adresse e-mail est correcte et vérifiez votre dossier de courrier indésirable."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/he.lproj/InfoPlist.strings b/WooCommerce/Resources/he.lproj/InfoPlist.strings index a92384c43c2..948dfdfc90f 100644 --- a/WooCommerce/Resources/he.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/he.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ כדי לצלם תמונות או סרטוני וידאו להוספה למוצרים שלך, לסרוק ברקוד ל-SKU של מוצר או לכרטיסטי תמיכה. NSLocationWhenInUseUsageDescription נדרשת גישה למיקום כדי לקבל תשלומים. + NSMicrophoneUsageDescription + השירות של Woo משתמש במיקרופון שלך כדי לאפשר לך להקליט אודיו בצילומי וידאו לספריית המדיה של החנות. NSPhotoLibraryUsageDescription כדי לשמור תמונות מהמצלמה לצורך תמונות של מוצרים, או כדי להוסיף תמונות או סרטוני וידאו למוצרים או לכרטיסי תמיכה. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/he.lproj/Localizable.strings b/WooCommerce/Resources/he.lproj/Localizable.strings index 07e76ab3970..13e42da1121 100644 --- a/WooCommerce/Resources/he.lproj/Localizable.strings +++ b/WooCommerce/Resources/he.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-14 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-26 15:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: he_IL */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "משפחת Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "הכספים הזמינים יופקדו ידנית, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "יתרות זמינות משולמות באופן אוטומטי, %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "הכספים הזמינים יופקדו ידנית, לפי בקשה."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "יתרות זמינות משולמות באופן ידני לפי בקשה."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "ערך הזמנות ממוצע"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "שורה מותאמת אישית ⁦%1$d⁩"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "חבילה מותאמת אישית"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "שגיאה בעדכון של הזמנה מס' ⁦%1$d⁩"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "בסביבות ה-%1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "רשימת האפשרויות המלאה"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "כספים נעשים זמינים לאחר תקופת המתנה בת ⁦%1$d⁩ ימים."; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "מלאי"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "מיאנמר"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "אין מידע"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "לא ניתן לאחזר את הקבלה."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "מצטערים, אפשר לערוך את ההזמנה הזאת רק באתר האינטרנט, מאחר שהיא משתמשת במטבע %1$@ והאתר שלך מוגדר למטבע %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "כל %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "כתובת למשלוח"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "מק\"ט"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "ביטול"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "טקסט CTA"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "קריאות לפעולה"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "טקסט התיאור לפרסומת ב-Blaze"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "לערוך את הפרסומת"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "טקסט CTA לא יכול לחרוג מ-⁦%1$d⁩ תווים"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "התיאור לא יכול להיות ריק"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "המערכת סורקת כדי לאתר קורא"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "הקבלה נשלחה אל %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "לשלוח קבלה באימייל"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "התשלום בוצע בהצלחה"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "להדפיס קבלה"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "הקבלה נשלחה אל %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "לשמור את הקבלה ולהמשיך"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "לרשום את פרטי העסקה בהערות ההזמנה"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "דמי הרשמה: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "להסתיר פרטי הפקדה"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "להציג פרטי הפקדה"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "כספים זמינים"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "בוטל"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "משוער"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "נכשל"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "במעבר"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "שולם"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "בהמתנה"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "לא ידוע"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "הפקדה אחרונה"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "למידע נוסף על המועד לקבלת הכספים שלך"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "כספים בהמתנה לאישור"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "מסמך במכשיר"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "במשלוחים למדינות שעונות על כללי המכס של האיחוד האירופי חובה לספק כעת תיאור ברור וספציפי לגבי כל פריט. לדוגמה, אם המשלוח כולל בגדים, יש לציין את סוגי הבגדים במשלוח (למשל, חולצות גברים, אפודות של נשים, ג'קטים של ילדים) כדי שהתיאור יענה על הדרישות. אחרת, ייתכן שמשלוחים יעוכבו או יחסמו במכס."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "כל %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "בכל יום"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "בכל חודש"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "ביום ה-%1$@ בכל חודש"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "נדרשת גישה למיקום כדי לקבל תשלומים."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "השירות של Woo משתמש במיקרופון שלך כדי לאפשר לך להקליט אודיו בצילומי וידאו לספריית המדיה של החנות."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "כדי לשמור תמונות מהמצלמה לצורך תמונות של מוצרים, או כדי להוסיף תמונות או סרטוני וידאו למוצרים או לכרטיסי תמיכה."; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "החזר כספי ידני"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "ידנית, לפי בקשה"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "קוראי כרטיסים"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "טוען יתרות..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 ההזמנה הושלמה"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "הגדרות"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "טוען יתרות..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "אודות Tap To Pay"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "בוצע"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "בוצע"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "יתרת תשלומים ב-Woo"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "יתרת תשלומים ב-Woo"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "יתרת תשלומים ב-Woo"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "יתרת תשלומים ב-Woo"; /* Display label for a product's subscription period when it is a single month. */ "month" = "חודש"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "להוסיף משלוח"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "ביטול"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "להזין אימייל"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "אימייל"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "לשלוח קבלה באימייל"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "שגיאה בשליחת הקבלה ללקוח באימייל. יש לנסות שוב."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "יש להזין כתובת אימייל תקפה."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "לשלוח ללקוח את הקבלה באימייל"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "להוסיף משלוח"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "תשלומים, הצמדה לתשלום, tap to pay, ‏WooCommerce, ‏Woo, תשלומים באופן אישי, לגבות תשלומים באופן אישי, תשלומים, קורא, קורא כרטיסים, להזמין קורא כרטיסים"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "להסתיר את פרטי התשלום"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "להציג את פרטי התשלום"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "יתרות זמינות"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "תשלום אחרון"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "למידע נוסף על המועד לקבלת היתרות שלך"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "בוטל"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "משוער"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "נכשל"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "במעבר"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "שולם"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "בהמתנה"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "לא ידוע"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "יתרות בהמתנה לאישור"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "התוכנית הסתיימה"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "לנסות אמצעי תשלום אחר"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "לנסות את התשלום שוב"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "עריכת הזמנה"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "שגיאה בהכנת התשלום"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "לנסות את התשלום שוב"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "רק רגע..."; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "הזמנה חדשה"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "קבלה"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "סכום ביניים"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "אופס, משהו השתבש. יש לנסות שוב!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "אם אין לך חשבון, אנחנו נשתמש בכתובת האימייל הזאת כדי ליצור אחד."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "לא הצלחנו לאתר חשבון WordPress.com שמקושר לשם המשתמש הזה. באפשרותך להזין אימייל כדי ליצור חשבון חדש."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "ביטול"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "להתחבר"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "יש לוודא שכתובת האימייל שלך נכונה ולבדוק שוב את התיבה של דואר הזבל."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat."; diff --git a/WooCommerce/Resources/id.lproj/InfoPlist.strings b/WooCommerce/Resources/id.lproj/InfoPlist.strings index 36b00b1c43b..02c1b9e1f75 100644 --- a/WooCommerce/Resources/id.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/id.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Diperlukan untuk mengambil foto atau merekam video untuk ditambahkan ke Produk, memindai barcode untuk SKU Produk, atau tiket dukungan. NSLocationWhenInUseUsageDescription Akses Lokasi diperlukan agar dapat menerima pembayaran. + NSMicrophoneUsageDescription + Woo menggunakan mikrofon Anda agar Anda dapat mengabadikan audio saat merekam video untuk pustaka media toko Anda. NSPhotoLibraryUsageDescription Diperlukan untuk menyimpan foto dari kamera untuk Gambar produk, atau menambahkan foto atau video ke Produk atau tiket dukungan. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/id.lproj/Localizable.strings b/WooCommerce/Resources/id.lproj/Localizable.strings index b72c43099b0..a5bf57a1f0e 100644 --- a/WooCommerce/Resources/id.lproj/Localizable.strings +++ b/WooCommerce/Resources/id.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-05 11:54:03+0000 */ +/* Translation-Revision-Date: 2024-11-26 09:54:05+0000 */ /* Plural-Forms: nplurals=2; plural=n > 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: id */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Keluarga Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Deposit dana yang tersedia dilakukan secara otomatis, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Dana yang tersedia dibayar secara otomatis, %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Deposit dana yang tersedia dilakukan secara manual, berdasarkan permintaan."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Dana yang tersedia dibayar secara manual berdasarkan permintaan."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Nilai Pesanan Rata-Rata"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Baris Kustom %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Paket Khusus"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Error saat memperbarui Pesanan #%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Diperkirakan %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Daftar Fitur Lengkap"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Dana telah tersedia setelah tertunda selama %1$d hari."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabon"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Format ITN tidak valid"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Pengenal Tidak Valid"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Nama Paket Tidak Valid"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Persediaan"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Myanmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "Tidak tersedia"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Tidak dapat mengambil tanda terima."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Maaf, Anda hanya bisa mengedit pesanan ini di web karena pesanan menggunakan %1$@ dan mata uang situs Anda adalah %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Setiap %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Tipe produk"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "Produk dengan Pengenal \"%@\" tidak dapat dibeli."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "Produk dengan Pengenal \"%@\" tidak ditemukan."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Pindai barcode atau Kode QR produk"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "ALAMAT PENGIRIMAN"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Batal"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Teks CTA"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Call to Action"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Teks deskripsi untuk Iklan Blaze"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Edit Iklan"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "Teks CTA tidak boleh melebihi %1$d karakter"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "Deskripsi tidak boleh kosong"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Memindai pembaca"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Tanda terima dikirimkan ke %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Email tanda terima"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Pembayaran berhasil"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Cetak tanda terima"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Tanda terima dikirimkan ke %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Simpan tanda terima dan lanjutkan"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Catat detail transaksi dalam catatan pesanan"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Biaya pendaftaran: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Sembunyikan detail deposit"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Tampilkan detail deposit"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Dana yang tersedia"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Dibatalkan"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Estimasi"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Gagal"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "Sedang Transit"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Dibayar"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Tertunda"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Tidak diketahui"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Deposit Terakhir"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Baca selengkapnya tentang kapan Anda akan menerima dana"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Dana yang tertunda"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Dokumen di perangkat"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Sekarang, pengiriman ke negara yang memberlakukan aturan bea cukai Uni Eropa (UE) mewajibkan Anda menguraikan setiap item. Contohnya, jika Anda mengirim pakaian, Anda harus menerangkan jenis pakaiannya (seperti, baju pria, rompi wanita, jaket pria) dalam deskripsinya untuk dapat diterima. Jika tidak, pengiriman akan tertunda atau tertahan di bea cukai."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "tiap %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "tiap hari"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "tiap bulan"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "tiap bulan pada tanggal %1$@"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Akses Lokasi diperlukan agar dapat menerima pembayaran."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo menggunakan mikrofon Anda agar Anda dapat mengabadikan audio saat merekam video untuk pustaka media toko Anda."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Diperlukan untuk menyimpan foto dari kamera untuk Gambar produk, atau menambahkan foto atau video ke Produk atau tiket dukungan."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "pengembalian dana manual"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manual, berdasarkan permintaan"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Pembaca kartu"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Memuat saldo ...."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Pesanan selesai"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Pengaturan"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Memuat saldo..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Tentang Ketuk untuk Bayar"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Ketuk untuk Bayar"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Selesai"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Selesai"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Saldo Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Saldo Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Saldo Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Saldo Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Bulan"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Tambahkan Pengiriman"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Batal"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Masukkan Email"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "Email"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Email Tanda Terima"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Terjadi error saat mengirim email tanda terima. Coba lagi."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Masukkan alamat email yang valid."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Email Tanda Terima kepada Pelanggan"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Tambahkan Pengiriman"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "pembayaran, ketuk untuk membayar, woocommerce, woo, pembayaran langsung, pembayaran langsung, terima pembayaran, pembayaran, pembaca, pembaca kartu, pesan pembaca kartu"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Sembuyikan rincian pembayaran"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Tunjukkan rincian pembayaran"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Dana yang tersedia"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Pembayaran Terakhir"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Baca selengkapnya tentang kapan Anda akan menerima dana"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Dibatalkan"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Estimasi"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Gagal"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "Sedang Transit"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Dibayar"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Tertunda"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Tidak diketahui"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Dana yang tertunda"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "paket berakhir"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Coba metode pembayaran lainnya"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Coba lagi pembayaran"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Edit pesanan"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Error saat menyiapkan pembayaran"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Coba lagi pembayaran"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Harap tunggu..."; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Pesanan baru"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Tanda terima"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotal"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Ups, sepertinya ada yang salah. Coba lagi!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Jika Anda belum mempunyai akun, kami akan menggunakan email ini untuk membuat akun."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Kami tidak dapat menemukan akun WordPress.com yang terhubung dengan nama pengguna ini. Anda dapat memasukkan email untuk membuat akun baru."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Batal"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Login"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Pastikan email Anda sudah benar dan periksa kembali folder spam Anda."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/it.lproj/InfoPlist.strings b/WooCommerce/Resources/it.lproj/InfoPlist.strings index 78bb8bf6adc..8f0108f54df 100644 --- a/WooCommerce/Resources/it.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/it.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Per scattare foto o video da aggiungere ai tuoi prodotti, scansiona il codice a barre per lo SKU del prodotto o i ticket di assistenza. NSLocationWhenInUseUsageDescription Per accettare i pagamenti, è necessario l'accesso alla posizione. + NSMicrophoneUsageDescription + Woo utilizza il tuo microfono per consentirti di acquisire l'audio durante la registrazione dei video per la libreria multimediale del tuo negozio. NSPhotoLibraryUsageDescription Per salvare le foto dalla fotocamera per le immagini di prodotto, per aggiungere foto o video ai tuoi Prodotti o per i ticket di assistenza. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/it.lproj/Localizable.strings b/WooCommerce/Resources/it.lproj/Localizable.strings index 4037f479703..db37837b3f7 100644 --- a/WooCommerce/Resources/it.lproj/Localizable.strings +++ b/WooCommerce/Resources/it.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-06 17:54:03+0000 */ +/* Translation-Revision-Date: 2024-11-28 16:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: it */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Famiglia Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "I fondi disponibili vengono depositati manualmente, ogni %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "I fondi disponibili vengono versati automaticamente, %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "I fondi disponibili vengono depositati manualmente, su richiesta."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "I fondi disponibili vengono versati manualmente, su richiesta."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Valore medio dell'ordine"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "%1$d riga personalizzata"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Pacchetto personalizzato"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Errore durante l'aggiornamento dell'ordine #%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Previsto per il giorno %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Elenco completo delle funzionalità"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "I fondi saranno disponibili dopo un periodo di attesa di %1$d giorni."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabon"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Formato numero di transizione interno non valido"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Identificativo non valido"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Nome pacchetto non valido"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Inventario"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Birmania"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N\/D"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Impossibile recuperare la ricevuta."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Spiacenti, puoi modificare questo ordine solo sul web, in quanto utilizza %1$@, mentre la valuta del tuo sito è %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Ogni %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Tipo di prodotto"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "Prodotto con Identificativo \"%@\" non acquistabile."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "Prodotto con Identificativo \"%@\" non trovato."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Scansiona il codice a barre o il codice QR del prodotto"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "INDIRIZZO DI SPEDIZIONE"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Annulla"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Testo dell'invito all'azione"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Invito all'azione"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Testo della descrizione per l'annuncio di Blaze"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Modifica annuncio"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "Il testo dell'invito all'azione non può superare i %1$d caratteri"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "La descrizione non può essere vuota"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Ricerca del lettore"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Una ricevuta è stata inviata a %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Ricevuta via e-mail"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Pagamento avvenuto correttamente"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Stampa ricevuta"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Una ricevuta è stata inviata a %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Salva la ricevuta e continua"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Registra i dettagli della transazione nella nota dell'ordine"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Costo di iscrizione: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Nascondi dettagli acconto"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Mostra dettagli acconto"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Fondi disponibili"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Annullato"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Stimato"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Non riuscito"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "In transito"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Pagato"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "In sospeso"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Sconosciuto"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Ultimo acconto"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Scopri di più su quando riceverai i fondi"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Fondi in sospeso"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Documento sul dispositivo"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "La spedizione in Paesi che seguono le regole doganali dell'Unione europea (UE) ora richiede la descrizione chiara di ogni elemento. Ad esempio, se stai inviando capi di vestiario, devi indicare il tipo di abbigliamento (come camicie da uomo, gilet da ragazza, giacca da ragazzo) affinché la descrizione sia accettabile. In caso contrario, le spedizioni potrebbero subire dei ritardi o essere fermate alla dogana."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "ogni %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "ogni giorno"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "ogni mese"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "il giorno %1$@ di ogni mese"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Per accettare i pagamenti, è necessario l'accesso alla posizione."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo utilizza il tuo microfono per consentirti di acquisire l'audio durante la registrazione dei video per la libreria multimediale del tuo negozio."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Per salvare le foto dalla fotocamera per le immagini di prodotto, per aggiungere foto o video ai tuoi Prodotti o per i ticket di assistenza."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "rimborso manuale"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manualmente, su richiesta"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Lettori di carte"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Caricamento dei saldi in corso..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Ordine completato"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Impostazioni"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Caricamento dei saldi in corso..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Informazioni su Tocca per pagare"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tocca per pagare"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Fatto"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Fatto"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Saldo di Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Saldo di Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Saldo di Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Saldo di Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Mese"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Aggiungi spedizione"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Annulla"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Inserisci l'e-mail"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-mail"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Ricevuta via e-mail"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Errore nell'invio della ricevuta via e-mail. Riprova."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Inserisci un indirizzo e-mail valido."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Ricevuta via e-mail al cliente"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Aggiungi spedizione"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "pagamenti, tocca per pagare, woocommerce, woo, pagamenti di persona, pagamenti di persona riscuoti pagamento, pagamenti, lettore, lettore di carte, ordina lettore di carte"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Nascondi i dettagli del versamento"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Mostra i dettagli del versamento"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Fondi disponibili"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Ultimo versamento"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Scopri di più su quando riceverai i fondi"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Annullato"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Stimato"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Non riuscito"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "In transito"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Pagato"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "In sospeso"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Sconosciuto"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Fondi in sospeso"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "piano scaduto"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Prova un altro metodo di pagamento"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Prova ancora il pagamento"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Modifica ordine"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Errore di preparazione del pagamento"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Prova di nuovo il pagamento"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Attendi…"; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Nuovo ordine"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Ricevuta"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotale"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Siamo spiacenti, si è verificato un problema. Riprova."; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Se non hai un account, useremo questa e-mail per crearne uno."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Non riusciamo a trovare un account WordPress.com collegato a questo nome utente. Puoi inserire un'e-mail per creare un nuovo account."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Annulla"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Accedi"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Assicurati che la tua e-mail sia corretta e controlla la cartella dello spam."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/ja.lproj/InfoPlist.strings b/WooCommerce/Resources/ja.lproj/InfoPlist.strings index 6cd2ba0668f..ad5274c1531 100644 --- a/WooCommerce/Resources/ja.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/ja.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ 商品に追加する写真や動画を撮影するには、商品 SKU のバーコードまたはサポートチケットをスキャンします。 NSLocationWhenInUseUsageDescription 支払いを受け取るには位置情報へのアクセスが必要です。 + NSMicrophoneUsageDescription + Woo はストアのメディアライブラリ用に動画を録画する際、音声を取得できるようマイクを使用します。 NSPhotoLibraryUsageDescription 商品画像用にカメラの写真を保存したり、商品やサポートチケットに写真や動画を追加したりするためです。 OpenOrdersAction.Title diff --git a/WooCommerce/Resources/ja.lproj/Localizable.strings b/WooCommerce/Resources/ja.lproj/Localizable.strings index b71c2de410c..4fd5a6feb36 100644 --- a/WooCommerce/Resources/ja.lproj/Localizable.strings +++ b/WooCommerce/Resources/ja.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-12 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-27 09:54:04+0000 */ /* Plural-Forms: nplurals=1; plural=0; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: ja_JP */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic ファミリー"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "利用可能な資金は%1$@に自動で入金されます。"; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "利用可能な資金は%1$@に自動で支払われます。"; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "利用可能な資金はリクエストに応じて手動で入金されます。"; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "利用可能な資金はリクエストに応じて手動で支払われます。"; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "平均注文金額"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "カスタムライン%1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "カスタム荷物"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "注文番号%1$dの更新中にエラーが発生しました"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "予定日%1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "全機能一覧"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "資金は%1$d日間保留された後に利用可能になります。"; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "在庫"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "ミャンマー"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "該当なし"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "領収書を取得できません。"; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "この注文は %1$@ を使用しており、サイトの通貨は %2$@ であるため、編集はウェブ上でしか行えません。"; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "%1$@ %2$@毎"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "配送先住所"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "キャンセル"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA テキスト"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "行動喚起 (CTA)"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Blaze 広告の説明テキスト"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "広告を編集"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA テキストは%1$d文字以下にしてください"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "説明を空にすることはできません"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Reader をスキャンしています"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "領収書を %1$@ に送信しました"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "領収書をメールで送信"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "支払いに成功しました"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "領収書を印刷"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "領収書を %1$@ に送信しました"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "領収書を保存して続行"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "トランザクションの詳細を注文メモに記録する"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "登録費用: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "デポジットの詳細を非表示"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "デポジットの詳細を表示"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "利用可能な資金"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "キャンセル済み"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "予定"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "失敗"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "輸送中"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "有料"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "承認待ち"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "不明"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "最後のデポジット"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "資金の受け取りタイミングについて詳しくはこちら"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "保留中の資金"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "端末上のドキュメント"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "欧州連合 (EU) の関税法規則に従う国への発送は、すべての品目について明確な説明が求められるようになりました。 たとえば衣類を送る場合、どのようなタイプの衣類であるか (例: 男性用シャツ、女児用ベスト、男児用上着など) を明記しなければ認められません。 これを怠ると、配送が税関で遅延または中断されることがあります。"; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "毎週%1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "毎日"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "毎月"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "毎月%1$@"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "支払いを受け取るには位置情報へのアクセスが必要です。"; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo はストアのメディアライブラリ用に動画を録画する際、音声を取得できるようマイクを使用します。"; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "商品画像用にカメラの写真を保存したり、商品やサポートチケットに写真や動画を追加したりするためです。"; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "手動返金"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "要求に応じて手動"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "カードリーダー"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "残高を読み込んでいます…"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉注文が完了しました"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "設定"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "残高を読み込んでいます…"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Tap to Pay について"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap To Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "完了"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "完了"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo 支払い残高"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo 支払い残高"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo 支払い残高"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo 支払い残高"; /* Display label for a product's subscription period when it is a single month. */ "month" = "月"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "配送料を追加"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "キャンセル"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "メールアドレスを入力"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "メールアドレス"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "領収書をメールで送信"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "メールでの領収書の送信中にエラーが発生しました。 もう一度お試しください。"; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "有効なメールアドレスを入力してください。"; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "顧客に領収書をメールで送信"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "配送料を追加"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "支払い, tap to pay, woocommerce, woo, オフラインでの支払い, オフラインでの支払い, 支払いを受け取る, 支払い, リーダー, カードリーダー, カードリーダーを注文"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "支払い詳細を非表示"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "支払い詳細を表示"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "利用可能な資金"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "最終の支払い"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "資金の受け取りタイミングについて詳しくはこちら"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "キャンセル済み"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "予定"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "失敗"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "輸送中"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "有料"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "承認待ち"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "不明"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "保留中の資金"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "プランが終了しました"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "別の支払い方法を試す"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "再度支払いを試す"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "注文を編集"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "支払い準備エラー"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "再度支払いを試す"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "しばらくお待ちください..."; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "新しい注文"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "領収書"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "小計"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "エラーが発生しました。 もう一度お試しください。"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "アカウントをお持ちでない場合は、このメールアドレスを使用してアカウントを作成いたします。"; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "このユーザー名に接続された WordPress.com アカウントが見つかりません。 メールアドレスを入力して新規アカウントを作成できます。"; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "キャンセル"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "ログイン"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "メールアドレスが正しいことを確認し、スパムフォルダーを再確認してください。"; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/ko.lproj/InfoPlist.strings b/WooCommerce/Resources/ko.lproj/InfoPlist.strings index 3d9c680a48c..94a52e2e8a0 100644 --- a/WooCommerce/Resources/ko.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/ko.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ 사진 또는 비디오를 촬영하여 상품, 상품 SKU 스캔 바코드 또는 지원 티켓에 추가하는 방법입니다. NSLocationWhenInUseUsageDescription 결제를 수락하려면 위치 접근 권한이 필요합니다. + NSMicrophoneUsageDescription + Woo에서 스토어의 미디어 라이브러리에 사용할 비디오를 녹화할 때 마이크를 사용하여 오디오를 캡처할 수 있습니다. NSPhotoLibraryUsageDescription 상품 이미지의 카메라 사진을 저장하거나 사진 또는 비디오를 상품 또는 지원 티켓에 추가하는 방법입니다. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/ko.lproj/Localizable.strings b/WooCommerce/Resources/ko.lproj/Localizable.strings index e6acd9eb401..da1e809f404 100644 --- a/WooCommerce/Resources/ko.lproj/Localizable.strings +++ b/WooCommerce/Resources/ko.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-06 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-26 09:54:05+0000 */ /* Plural-Forms: nplurals=1; plural=0; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: ko_KR */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic 가족"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "사용 가능한 자금이 자동으로 예치됩니다(%1$@)."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "사용 가능한 자금이 자동으로 지급됩니다(%1$@)."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "사용 가능한 자금이 요청 시 수동으로 예치됩니다."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "사용 가능한 자금이 요청 시 수동으로 지급됩니다."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "평균 주문 값"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "사용자 정의 라인 %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "사용자 정의 패키지"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "주문 #%1$d 업데이트 중 오류 발생"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "%1$@(으)로 예상"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "전체 기능 목록"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "%1$d일 대기 후 자금을 이용할 수 있게 됩니다."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "가봉"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "유효하지 않은 ITN 형식"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "유효하지 않은 식별자"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "유효하지 않은 패키지 이름"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "재고"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "미얀마"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "해당 없음"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "영수증을 검색할 수 없습니다."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "죄송합니다. 이 주문에서는 %1$@을(를) 사용하고 사이트의 통화는 %2$@이므로 웹에서만 이 주문을 편집할 수 있습니다."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "%1$@당 %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "제품 유형"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "식별자가 \"%@\"인 상품은 구매할 수 없습니다."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "식별자가 \"%@\"인 상품을 찾을 수 없습니다."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "상품 바코드 또는 QR 코드 스캔"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "배송지"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "취소"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA 텍스트"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "행동 유도"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Blaze 광고용 설명 텍스트"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "광고 편집"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA 텍스트는 %1$d자 이하여야 합니다."; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "설명은 비워 둘 수 없음"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "리더 스캔 중"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "영수증이 %1$@(으)로 발송됨"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "이메일 영수증"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "결제 성공"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "영수증 인쇄"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "영수증이 %1$@(으)로 발송됨"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "영수증 저장 및 계속"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "주문 메모에 거래 상세 정보 기록"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "등록 수수료: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "예치금 상세 정보 숨기기"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "예치금 상세 정보 표시"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "사용 가능한 자금"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "취소됨"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "예상됨"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "실패"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "전환 중"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "결제됨"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "대기 중"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "알 수 없음"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "마지막 예치금"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "언제 자금을 받게 되는지 더 알아보기"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "대기 중인 자금"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "기기의 문서"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "EU(유럽연합) 세관 규칙을 따르는 국가로 배송 시 모든 아이템을 명확하게 설명해야 합니다. 예를 들어, 의류를 발송하는 경우 설명이 수락될 수 있으려면 의류 유형을 표시해야 합니다(예: 남성용 셔츠, 여아용 조끼, 남아용 재킷). 그렇지 않으면 배송이 세관에서 지체되거나 차단될 수 있습니다."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "%1$@마다"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "매일"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "매월"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "매월 %1$@에"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "결제를 수락하려면 위치 접근 권한이 필요합니다."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo에서 스토어의 미디어 라이브러리에 사용할 비디오를 녹화할 때 마이크를 사용하여 오디오를 캡처할 수 있습니다."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "상품 이미지의 카메라 사진을 저장하거나 사진 또는 비디오를 상품 또는 지원 티켓에 추가하는 방법입니다."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "수동 환불"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "요청 시 수동으로"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "카드 리더"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "잔액 로드 중..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 주문 완료됨"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "설정"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "잔액 로드 중..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Tap to Pay 개요"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "완료"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "완료"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo Payments 잔액"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo Payments 잔액"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo Payments 잔액"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo Payments 잔액"; /* Display label for a product's subscription period when it is a single month. */ "month" = "월"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "배송 추가"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "취소"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "이메일 입력"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "이메일"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "이메일 영수증"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "이메일 영수증 발송 중 오류가 발생했습니다. 다시 시도해 주세요."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "유효한 이메일 주소를 입력하세요."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "고객에게 이메일 영수증 발송"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "배송 추가"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "결제, 탭 투 페이, 우커머스, 우, 대면 결제, 대면 결제, 대면 결제 받기, 결제, 리더, 카드 리더, 주문 카드 리더"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "지급금 상세 정보 숨기기"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "지급금 상세 정보 표시"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "사용 가능한 자금"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "지난 지급금"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "언제 자금을 받게 되는지 더 알아보기"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "취소됨"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "예상됨"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "실패"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "전환 중"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "결제됨"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "대기 중"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "알 수 없음"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "대기 중인 자금"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "요금제 종료됨"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "다른 결제 수단 시도"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "다시 결제 시도"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "주문 수정"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "결제 준비 오류"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "다시 결제 시도"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "기다려주세요..."; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "새 주문"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "영수증"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "소계"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "오류가 발생했습니다. 다시 시도해 보세요!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "계정이 없다면 이 이메일을 사용하여 계정을 만드시면 됩니다."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "이 사용자명에 연결된 워드프레스닷컴 계정을 찾을 수 없습니다. 이메일을 입력하여 새 계정을 만들 수 있습니다."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "취소"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "로그인"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "이메일이 올바른지 확인하고 스팸 폴더를 다시 확인하세요."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/nl.lproj/InfoPlist.strings b/WooCommerce/Resources/nl.lproj/InfoPlist.strings index a67f78abb9d..d2071ef9563 100644 --- a/WooCommerce/Resources/nl.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/nl.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Scan de barcode voor product-SKU of ondersteuningstickets om foto's of video's toe te voegen aan je producten. NSLocationWhenInUseUsageDescription Locatietoegang is vereist om betalingen aan te nemen. + NSMicrophoneUsageDescription + Woo gebruikt je microfoon zodat je audio kan opnemen terwijl je video's maakt voor de mediabibliotheek van je winkel. NSPhotoLibraryUsageDescription Om foto's van je camera op te slaan als productafbeeldingen, of om foto's of video's toe te voegen aan je producten of ondersteuningstickets. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/nl.lproj/Localizable.strings b/WooCommerce/Resources/nl.lproj/Localizable.strings index c5bf9991d4e..b5a667dc406 100644 --- a/WooCommerce/Resources/nl.lproj/Localizable.strings +++ b/WooCommerce/Resources/nl.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-07 13:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-27 11:54:04+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: nl */ @@ -950,11 +950,8 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic Family"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Beschikbare bedragen worden automatisch %1$@ gestort."; - -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Beschikbare bedragen worden handmatig op verzoek gestort."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Beschikbare bedragen worden op verzoek handmatig uitbetaald."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Gemiddelde bestellingswaarde"; @@ -2070,7 +2067,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Aangepaste regel %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Aangepast pakket"; /* Label for one of the filters in order date range @@ -2820,7 +2818,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Fout tijdens bijwerken van bestelnr.%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Gesch. %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3107,9 +3105,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Volledige lijst van functies"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Bedragen komen na %1$d dagen in de wacht beschikbaar."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabon"; @@ -3654,6 +3655,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Ongeldige ITN-indeling"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Ongeldige identificator"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Ongeldige pakketnaam"; @@ -3692,6 +3696,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Inventaris"; @@ -4399,7 +4404,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Myanmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N.v.t."; /* Name text field placeholder @@ -4888,6 +4893,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Kan kwitantie niet ophalen."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Ja kan deze bestelling alleen op het web bewerken, omdat hij gebruikmaakt van %1$@ en je site gebruikmaakt van de valuta %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Elke %1$@ %2$@"; @@ -5556,6 +5564,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Producttype"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "Product met de identificator '%@' niet verkrijgbaar."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "Product met de identificator '%@' niet gevonden."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Streepjescode of QR-code van product scannen"; @@ -6013,11 +6027,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "Verzendadres"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9659,6 +9670,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Annuleren"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA-tekst"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Call-to-action"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Omschrijving voor de Blaze-advertentie"; @@ -9689,6 +9706,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Advertentie bewerken"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "Tekst voor call-to-action (CTA) mag niet meer dan %1$d tekens bevatten"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "De omschrijving mag niet leeg zijn"; @@ -9963,6 +9983,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Scannen op lezer"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Er is een betalingsbewijs verzonden naar %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Betalingsbewijs e-mailen"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Betaling gelukt"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Betalingsbewijs afdrukken"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Er is een betalingsbewijs verzonden naar %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Sla betalingsbewijs op en ga verder"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Noteer transactiedetails in bestelnotitie"; @@ -10446,45 +10484,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Aanmeldkosten: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Details verbergen"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Details tonen"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Beschikbare bedragen"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Geannuleerd"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Geschat"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Mislukt"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "Onderweg"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Betaald"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "In behandeling"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Onbekend"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Vorige storting"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Meer informatie over wanneer je je bedragen ontvangt"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Bedragen in behandeling"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Document op apparaat"; @@ -10527,16 +10526,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Bij het verzenden naar landen waarin douanewetgeving van de Europese Unie (EU) geldt, moet je elk artikel duidelijk beschrijven. Bijvoorbeeld, als je kleding verzendt moet je in de beschrijving duidelijk aangeven wat voor soort kleding het is (bijv. herenshirt, damesvest, kinderjas). Anders kunnen verzendingen vertraging oplopen of stil komen staan bij de douane."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "elke %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "elke dag"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "elke maand"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "elke maand op de %1$@"; /* Placeholder for the site url textfield. @@ -10696,6 +10695,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Locatietoegang is vereist om betalingen aan te nemen."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo gebruikt je microfoon zodat je audio kan opnemen terwijl je video's maakt voor de mediabibliotheek van je winkel."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Om foto's van je camera op te slaan als productafbeeldingen, of om foto's of video's toe te voegen aan je producten of ondersteuningstickets."; @@ -10765,7 +10767,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "handmatige restitutie"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "handmatig, op verzoek"; /* Menu option for taking an image or video with the device's camera. */ @@ -10801,9 +10803,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Kaartlezers"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Saldo's laden ..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Bestelling voltooid"; @@ -10848,6 +10847,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Instellingen"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Saldo's laden ..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Over Tap to Pay"; @@ -10858,13 +10860,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Gereed"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Gereed"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Tegoed Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Tegoed Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Tegoed Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Tegoed Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Maand"; @@ -10953,6 +10955,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Verzending toevoegen"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Annuleren"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "E-mail invoeren"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-mail"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Betalingsbewijs e-mailen"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Fout bij e-mailen van betalingsbewijs. Probeer het opnieuw."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Voer een geldig e-mailadres in."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Betalingsbewijs e-mailen aan klant"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Verzending toevoegen"; @@ -11182,6 +11205,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "betalingen, Tap to Pay, woocommerce, woo, fysieke betalingen, betaling innen, betalingen, lezer, kaartlezer, kaartlezer bestellen"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Uitbetalingsgegevens verbergen"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Uitbetalingsgegevens tonen"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Beschikbare bedragen"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Laatste uitbetaling"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Meer informatie over wanneer je je bedragen ontvangt"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Geannuleerd"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Geschat"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Mislukt"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "Onderweg"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Betaald"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "In behandeling"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Onbekend"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Bedragen in behandeling"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "abonnement beëindigd"; @@ -11242,13 +11304,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Probeer een andere betaalmethode"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Probeer opnieuw te betalen"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Bestelling bewerken"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Fout bij betaling"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Probeer opnieuw te betalen"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Een moment geduld..."; @@ -11600,6 +11664,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Nieuwe bestelling"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Factuur"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotaal"; @@ -12700,12 +12767,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Oeps, er is iets fout gegaan. Probeer het nog eens!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Als je nog geen account hebt, gebruiken we dit e-mailadres om er een aan te maken."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "We kunnen geen WordPress.com-account vinden met deze gebruikersnaam. Je kan een e-mailadres invoeren om een nieuw account aan te maken."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Annuleren"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Inloggen"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Controleer of je e-mailadres klopt en check je spammap."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/pt-BR.lproj/InfoPlist.strings b/WooCommerce/Resources/pt-BR.lproj/InfoPlist.strings index e52a7e3c72a..14f8e33c537 100644 --- a/WooCommerce/Resources/pt-BR.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/pt-BR.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Para tirar fotos ou gravar vídeos a serem adicionados aos seus produtos, escanear o código de barras da SKU do produto ou usar em tíquetes de suporte. NSLocationWhenInUseUsageDescription O acesso à localização é obrigatório para aceitar pagamentos. + NSMicrophoneUsageDescription + O Woo usa seu microfone para que você possa captar áudio ao gravar vídeos para a biblioteca de mídia da sua loja. NSPhotoLibraryUsageDescription Para salvar fotos de produtos que estão na câmera ou adicionar fotos ou vídeos aos produtos ou tíquetes de suporte. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/pt-BR.lproj/Localizable.strings b/WooCommerce/Resources/pt-BR.lproj/Localizable.strings index 4995e0af11b..9a06d63bcfa 100644 --- a/WooCommerce/Resources/pt-BR.lproj/Localizable.strings +++ b/WooCommerce/Resources/pt-BR.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-05 20:54:06+0000 */ +/* Translation-Revision-Date: 2024-11-25 16:54:05+0000 */ /* Plural-Forms: nplurals=2; plural=(n > 1); */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: pt_BR */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Família Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Os fundos disponíveis são depositados automaticamente, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "O dinheiro disponível é depositado automaticamente %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Os fundos disponíveis são depositados manualmente, sob demanda."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "O dinheiro disponível é depositado manualmente, sob demanda."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Valor médio por pedido"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Linha personalizada %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Pacote personalizado"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Erro ao atualizar o pedido nº %1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Estimada para %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Lista completa de funcionalidades"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Os fundos ficam disponíveis após %1$d dias pendentes."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabão"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Formato inválido de ITN"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Identificador inválido"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Nome de pacote inválido"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Estoque"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Mianmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N\/D"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Não foi possível recuperar o recibo."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Você só pode editar este pedido no navegador da Web, pois ele usa %1$@, e a moeda do seu site é %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "A cada %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Tipo de produto"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "Produto com o identificador \"%@\" não está à venda."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "Produto com o identificador \"%@\" não encontrado."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Ler código de barras ou código QR do produto"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "ENDEREÇO DE ENTREGA"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Cancelar"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Texto da CTA"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Chamada para ação"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Texto da descrição para o anúncio do Blaze"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Editar anúncio"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "O texto da CTA não pode ter mais de %1$d caracteres"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "A descrição não pode ficar em branco"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Procurando um leitor"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Um recibo foi enviado para %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Enviar recibo por e-mail"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Pagamento bem-sucedido"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Imprimir recibo"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Um recibo foi enviado para %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Salvar recibo e continuar"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Registrar os dados da transação nas anotações do pedido"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Taxa de inscrição: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Ocultar detalhes do depósito"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Mostrar detalhes do depósito"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Dinheiro disponível"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Cancelado"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Estimado"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Com falha"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "Em trânsito"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Pago"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Pendente"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Desconhecido"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Último depósito"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Saiba mais sobre quando você receberá seu dinheiro"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Dinheiro pendente"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Documento no dispositivo"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "O envio para países que seguem as regras alfandegárias da União Europeia (UE) agora exige que você descreva de forma detalhada cada item. Por exemplo, se você está enviando roupas, deve indicar que tipo de roupa (camisas masculinas, colete feminino, jaqueta masculina) para que a descrição seja aceitável. Caso contrário, os envios podem sofrer atrasos ou ficar interditados na alfândega."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "toda(o) %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "todo dia"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "todo mês"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "todo mês em %1$@"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "O acesso à localização é obrigatório para aceitar pagamentos."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "O Woo usa seu microfone para que você possa captar áudio ao gravar vídeos para a biblioteca de mídia da sua loja."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Para salvar fotos de produtos que estão na câmera ou adicionar fotos ou vídeos aos produtos ou tíquetes de suporte."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "reembolso manual"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manualmente, sob medida"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Leitores de cartão"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Carregando saldos..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Pedido concluído"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Configurações"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Carregando saldos..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Sobre o Tap to Pay"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Concluir"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Concluído"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Saldo do Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Saldo do Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Saldo do Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Saldo do Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Mês"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Adicionar envio"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Cancelar"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Inserir e-mail"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-mail"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Enviar recibo por e-mail"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Erro ao enviar o recibo por e-mail. Tente novamente."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Digite um endereço de e-mail válido."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Enviar recibo por e-mail para o cliente"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Adicionar envio"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "pagamentos, tap to pay, woocommerce, woo, pagamentos presenciais, pagamento presencial, receber pagamento, pagamentos, leitor, leitor de cartão, pedir leitor de cartão"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Ocultar detalhes do pagamento"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Mostrar detalhes do pagamento"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Dinheiro disponível"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Último pagamento"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Saiba mais sobre quando você receberá seu dinheiro"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Cancelado"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Estimado"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Com falha"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "Em trânsito"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Pago"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Pendente"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Desconhecido"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Dinheiro pendente"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "plano encerrado"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Tentar outro método de pagamento"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Tentar pagar novamente"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Editar pedido"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Erro ao preparar o pagamento"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Tentar pagar novamente"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Aguarde..."; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Novo pedido"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Recibo"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Subtotal"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Ops, ocorreu um erro. Tente novamente."; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Se você não tiver uma conta, usaremos este e-mail para criar uma."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Não encontramos nenhuma conta do WordPress.com conectada a este nome de usuário. Insira um e-mail para criar uma conta nova."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Cancelar"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Fazer login"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Confirme se o e-mail está correto e verifique sua pasta de spam."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "https:\/\/www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/release_notes.txt b/WooCommerce/Resources/release_notes.txt index cc22e8f345e..56acc06117c 100644 --- a/WooCommerce/Resources/release_notes.txt +++ b/WooCommerce/Resources/release_notes.txt @@ -1,3 +1,3 @@ -In just two weeks, we've jam-packed this release. There's GTIN global product identifier support, and you can edit the call-to-action in Blaze campaigns. -Store setup for in-person payments is faster, receipt sent confirmations clearer, and testing Tap to Pay on iPhone is smoother. Plus, we've improved dashboard statistics and squashed some bugs. +This update brings enhanced reliability and clarity to your WooCommerce experience! Enjoy improved Jetpack setup, smoother media handling, and better product and payment workflows. We’ve also optimized storage and addressed key UI issues to elevate performance. Plus, Tap to Pay onboarding now guides you with ease! + diff --git a/WooCommerce/Resources/ru.lproj/InfoPlist.strings b/WooCommerce/Resources/ru.lproj/InfoPlist.strings index dca173d85a4..7a00511697b 100644 --- a/WooCommerce/Resources/ru.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/ru.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Чтобы снимать фотографии и видеоролики и добавлять их в описание товаров и заявки в службу поддержки, а также сканировать штрихкоды товаров. NSLocationWhenInUseUsageDescription Чтобы принимать платежи, требуется доступ к данным о местоположении. + NSMicrophoneUsageDescription + Woo требуется доступ к вашему микрофону, чтобы вы могли записывать видео со звуком для библиотеки файлов вашего магазина. NSPhotoLibraryUsageDescription Чтобы сохранять фотографии с камеры в качестве изображений товаров или добавлять фотографии и видеоролики в описание товаров и заявки в службу поддержки. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/ru.lproj/Localizable.strings b/WooCommerce/Resources/ru.lproj/Localizable.strings index 3add24a4e1f..cd6e5b74747 100644 --- a/WooCommerce/Resources/ru.lproj/Localizable.strings +++ b/WooCommerce/Resources/ru.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-06 17:54:03+0000 */ +/* Translation-Revision-Date: 2024-11-26 22:54:04+0000 */ /* Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2); */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: ru */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Семейство Automattic"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Доступные средства вносятся на счёт автоматически, %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Доступные средства выплачиваются автоматически, %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Доступные средства вносятся на счёт вручную, по запросу."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Доступные средства выплачиваются вручную по запросу."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Средняя стоимость заказа"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Пользовательский артикул %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Индивидуальный пакет"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Ошибка обновления заказа № %1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Прибл. %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Полный список возможностей"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Денежные средства станут доступны после утверждения в течение %1$d дн."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Габон"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Недопустимый формат ITN"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Неверный идентификатор"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Недопустимое название посылки"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Наличие"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Мьянма"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "Н\/Д"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Не удалось получить чек."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "К сожалению, этот заказ можно редактировать только в Интернете, так как он сделан в %1$@, а валюта вашего сайта — %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Каждые %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Тип товара"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "Товар с идентификатором «%@» недоступен для покупки."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "Товар с идентификатором «%@» не найден."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Сканировать штрихкод или QR-код товара"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "АДРЕС ДОСТАВКИ"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "Артикул"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Отмена"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "Текст призыва к действию"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Призыв к действию"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Текст описания рекламного объявления Blaze"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Редактировать рекламное объявление"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "Текст призыва к действию не может превышать %1$d симв."; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "Описание не может быть пустым"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Поиск устройства чтения"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Чек отправлен по адресу %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Отправить чек по эл. почте"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Платёж выполнен"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Печать чека"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Чек отправлен по адресу %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Сохранить чек и продолжить"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Записывать данные транзакции в примечаниях к заказам"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Регистрационный сбор: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Скрыть сведения о предоплате"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Показать сведения о предоплате"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Доступные средства"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Отменено"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Ожидается"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Сбой"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "В пути"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Оплачено"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "На утверждении"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Неизвестно"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Последняя оплата"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Узнайте, когда вы сможете получить ваши средства"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Средства на утверждении"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Документ на устройстве"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Отправляя товары в страны, которые следуют таможенным правилам Европейского союза (ЕС), вы теперь должны точно и понятно описать каждую позицию. Так, при отправке одежды необходимо указать её тип (например, мужские рубашки, жилет для девочки, куртка для мальчика), чтобы описание было приемлемым. В ином случае может произойти задержка или приостановка доставки на таможне."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "каждые %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "ежедневно"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "ежемесячно"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "ежемесячно %1$@"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Чтобы принимать платежи, требуется доступ к данным о местоположении."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo требуется доступ к вашему микрофону, чтобы вы могли записывать видео со звуком для библиотеки файлов вашего магазина."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Чтобы сохранять фотографии с камеры в качестве изображений товаров или добавлять фотографии и видеоролики в описание товаров и заявки в службу поддержки."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "возврат вручную"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "вручную, по запросу"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Устройства чтения карт"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Загрузка балансов…"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Заказ выполнен"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Настройки"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Загрузка балансов…"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "О функции Tap to Pay (Оплата в касание)"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay (Оплата в касание)"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Готово"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Готово"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Баланс Woo Payments"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Баланс Woo Payments"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Баланс Woo Payments"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Баланс Woo Payments"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Месяц"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Добавить доставку"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Отмена"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Введите адрес электронной почты"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "Электронная почта"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Отправить чек по эл. почте"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Ошибка при отправке чека по эл. почте. Повторите попытку."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Укажите действительный адрес эл. почты."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Отправить чек клиенту"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Добавить доставку"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "платежи, оплата в касание, woocommerce, woo, очные платежи, получение оплаты, платежи, устройство чтения, устройство чтения карт, заказ устройства чтения карт"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Скрыть сведения о платеже"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Показать сведения о платеже"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Доступные средства"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Последний платёж"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Узнайте, когда вы сможете получить ваши средства"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Отменено"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Ожидается"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Произошла ошибка"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "В пути"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Оплачено"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "На утверждении"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Неизвестно"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Средства на утверждении"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "действие тарифного плана закончилось"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Попробуйте другой способ оплаты"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Повторить платёж"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Изменить заказ"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Ошибка при подготовке платежа"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Повторить платёж"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Подождите…"; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Новый заказ"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Чек"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Подытог"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Ошибка. Что-то пошло не так. Повторите попытку."; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Если у вас нет учётной записи, создайте её с этим адресом эл. почты."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Не удалось найти учётную запись WordPress.com, связанную с этим именем пользователя. Введите адрес эл. почты, чтобы создать новую учётную запись."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Отмена"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Вход"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Проверьте, правильно ли указан ваш адрес эл. почты, и ещё раз загляните в папку «Спам»."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/sv.lproj/InfoPlist.strings b/WooCommerce/Resources/sv.lproj/InfoPlist.strings index 89a6de94561..7f8499bb77c 100644 --- a/WooCommerce/Resources/sv.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/sv.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ För att ta foton eller videoklipp som du kan lägga till för dina produkter, för att skanna en streckkod och hitta ett produkt-SKU eller för supportärenden. NSLocationWhenInUseUsageDescription Platsåtkomst krävs för att ta emot betalningar. + NSMicrophoneUsageDescription + Woo använder din mikrofon så att du kan spela in ljud när du spelar in videor för din butiks mediabibliotek. NSPhotoLibraryUsageDescription För att spara foton från kameran för produktbilder, eller för att lägga till foton eller videoklipp till dina produkter eller supportärenden. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/sv.lproj/Localizable.strings b/WooCommerce/Resources/sv.lproj/Localizable.strings index 0c42f6f420c..381b771dbdc 100644 --- a/WooCommerce/Resources/sv.lproj/Localizable.strings +++ b/WooCommerce/Resources/sv.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-08 22:11:33+0000 */ +/* Translation-Revision-Date: 2024-11-25 16:54:05+0000 */ /* Plural-Forms: nplurals=2; plural=n != 1; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: sv_SE */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic-familjen"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Tillgängliga medel sätts in automatiskt %1$@."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Tillgängliga medel betalas ut automatiskt %1$@."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Tillgängliga medel sätts in manuellt på begäran."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Tillgängliga medel betalas ut manuellt på begäran."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Genomsnittligt beställningsvärde"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Anpassad rad %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Anpassat paket"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "Det gick inte att uppdatera beställning #%1$d"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Beräknat %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Fullständig funktionslista"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Medlen blir tillgängliga efter att ha inväntat granskning i %1$d dagar."; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Lagersaldo"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Myanmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N\/A"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Kan inte hämta kvitto."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Du kan bara redigera den här ordern på webben, eftersom den använder %1$@, och din valutan för din webbplats är %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Var %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "LEVERANSADRESS"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "Artikelnr"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "Avbryt"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA-text"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Uppmaning till åtgärd"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Beskrivningstext för Blaze-annonsen"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Redigera annons"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA-text får inte överstiga %1$d tecken"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "Beskrivning kan inte vara tom"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Skannar efter läsare"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "Ett kvitto har skickats till %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Kvitto via e-post"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Betalning lyckades"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Skriv ut kvitto"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "Ett kvitto har skickats till %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Spara kvitto och fortsätt"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "Registrera transaktionsinformation i beställningsanteckning"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Registreringsavgift: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Dölj insättningsuppgifter"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Visa insättningsuppgifter"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Tillgängliga medel"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "Avbruten"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Beräknad"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Misslyckades"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "På väg"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Betald"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Väntande"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Okänt"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Senaste insättning"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Läs mer om när du får dina medel"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Medel som inväntar granskning"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Dokument på enhet"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Leverans till länder som följer EU:s tullregler kräver nu att du tydligt beskriver varje vara. Om du till exempel skickar kläder måste du ange vilken typ av kläder det är (t.ex. herrskjortor, flickvästar, pojkjackor) för att beskrivningen ska vara godtagbar. Annars kan leveranser försenas eller avbrytas i tullen."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "varje %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "varje dag"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "varje månad"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "varje månad den %1$@"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Platsåtkomst krävs för att ta emot betalningar."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo använder din mikrofon så att du kan spela in ljud när du spelar in videor för din butiks mediabibliotek."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "För att spara foton från kameran för produktbilder, eller för att lägga till foton eller videoklipp till dina produkter eller supportärenden."; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "manuell återbetalning"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manuellt, på begäran"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Kortläsare"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Hämtar saldon …"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Beställning färdigbehandlad"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Inställningar"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Hämtar saldon …"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Om Tap to Pay"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap to Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Klar"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Klar"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo Betalningsbalans"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo-betalningssaldo"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo Betalningsbalans"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo-betalningssaldo"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Månad"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Lägg till frakt"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "Avbryt"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "Ange e-post"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-post"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Kvitto via e-post"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Ett fel uppstod när kvittot skickades via e-post. Försök igen."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Ange en giltig e-postadress."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Kvitto till kund via e-post"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Lägg till frakt"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "betalningar, tryck för att betala, woocommerce, woo, personliga betalningar, ta emot personliga betalningar, betalningar, läsare, kortläsare, beställ kortläsare"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Dölj utbetalningsdetaljer"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Visa utbetalningsdetaljer"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Tillgängliga medel"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Sista utbetalningen"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Läs mer om när du får dina medel"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "Avbruten"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Beräknad"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Misslyckad"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "På väg"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Betald"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Inväntar granskning"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Okänt"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Medel som inväntar granskning"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "paketet har avslutats"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Prova annan betalningsmetod"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Försök betala igen"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Redigera beställning"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Fel vid betalningsförberedelse"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Försök att genomföra betalningen igen"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Vänta …"; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Ny beställning"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Kvitto"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Delsumma"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Hoppsan, något gick fel. Försök igen!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Om du inte har något konto så använder vi den här e-postadressen för att skapa ett."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Vi hittar inget konto hos WordPress.com som är kopplat till det här användarnamnet. Du kan ange en e-postadress för att skapa ett nytt konto."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "Avbryt"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Logga in"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "Kontrollera att din e-postadress är korrekt och dubbelkolla din skräppostmapp."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/tr.lproj/InfoPlist.strings b/WooCommerce/Resources/tr.lproj/InfoPlist.strings index a70bca4d00d..2731a3298e9 100644 --- a/WooCommerce/Resources/tr.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/tr.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ Ürünlerinize eklemek üzere fotoğraf veya video çekmek, Ürün SKU'su barkodunu veya destek biletlerini taramak için. NSLocationWhenInUseUsageDescription Ödemeleri kabul etmek için konum erişimi gereklidir. + NSMicrophoneUsageDescription + Woo, mağazanızın ortam kütüphanesi için video kaydederken ses kaydı için mikrofonunuzu kullanır. NSPhotoLibraryUsageDescription Ürün görselleri için kameradan fotoğraf kaydetmek veya Ürünlerinize ya da destek biletlerinize fotoğraf veya video eklemek için. OpenOrdersAction.Title diff --git a/WooCommerce/Resources/tr.lproj/Localizable.strings b/WooCommerce/Resources/tr.lproj/Localizable.strings index f839d8a3956..e4e4768e689 100644 --- a/WooCommerce/Resources/tr.lproj/Localizable.strings +++ b/WooCommerce/Resources/tr.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-07 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-26 09:54:05+0000 */ /* Plural-Forms: nplurals=2; plural=(n > 1); */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: tr */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic ailesi"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "Mevcut fonlar otomatik olarak (%1$@) yatırılır."; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "Mevcut fonlar otomatik olarak (%1$@) ödenir."; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "Mevcut fonlar talep üzerine manuel olarak yatırılır."; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "Mevcut fonlar talep üzerine manuel olarak ödenir."; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "Ortalama sipariş değeri"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "Özel Hat %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "Özelleştirilmiş Paket"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "%1$d numaralı sipariş güncellenirken hata oluştu"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "Tahmini %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,9 +3111,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "Tüm Özellikler Listesi"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "Fonlar, %1$d gün beklemenin ardından kullanılabilir hale gelir."; +/* Format of the Global Unique Identifier on the Inventory Settings row */ +"GTIN, UPC, EAN, ISBN: %@" = "GTIN, UPC, EAN, ISBN: %@"; + /* Country option for a site address. */ "Gabon" = "Gabon"; @@ -3657,6 +3661,9 @@ which should be translated separately and considered part of this sentence. */ /* Error message for invalid format of ITN in Customs screen of Shipping Label flow */ "Invalid ITN format" = "Geçersiz ITN biçimi"; +/* Error when an empty Identifier is returned from the barcode scanner */ +"Invalid Identifier" = "Geçersiz Tanımlayıcı"; + /* The title of the alert when there is an error with the package name */ "Invalid Package Name" = "Geçersiz Paket Adı"; @@ -3695,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "Stok"; @@ -4402,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "Myanmar"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "Yok"; /* Name text field placeholder @@ -4891,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "Fatura alınamıyor."; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "Üzgünüz, bu siparişi yalnızca web üzerinden düzenleyebilirsiniz, çünkü bu sipariş %1$@ kullanıyor ve sitenizin para birimi %2$@."; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "Her %1$@ %2$@"; @@ -5559,6 +5570,12 @@ which should be translated separately and considered part of this sentence. */ /* Title of the Product Type row on Product main screen */ "Product type" = "Ürün türü"; +/* Error message when the scanner found a product but isn't purchasable.%@ is the Identifier code. */ +"Product with Identifier \"%@\" is not purchasable." = "\"%@\" Tanımlayıcısına sahip ürün satın alınamaz."; + +/* Error message when the scanner cannot find a matching product.%@ is the Identifier barcode. */ +"Product with Identifier \"%@\" not found." = "\"%@\" Tanımlayıcısına sahip ürün bulunamadı."; + /* The instruction text below the scan area in the barcode scanner for product barcode. */ "ProductBarcodeInputScanner.instructionText" = "Ürün barkodunu veya QR kodunu tarayın"; @@ -6016,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "GÖNDERİM ADRESİ"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9662,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "İptal et"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA metni"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "Eylem Çağrısı"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Blaze Reklamının açıklama metni"; @@ -9692,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "Reklamı düzenleyin"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA metni %1$d karakteri geçemez"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "Açıklama bol olamaz"; @@ -9966,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "Okuyucu için taranıyor"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "%1$@ adresine bir fatura gönderildi"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "Faturayı e-posta ile gönderin"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "Ödeme başarılı"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "Fatura yazdır"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "%1$@ adresine bir fatura gönderildi"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "Faturayı kaydet ve devam et"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "İşlem ayrıntılarını sipariş notunda kaydet"; @@ -10449,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "Kayıt ücreti: %1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "Depozito ayrıntılarını gizle"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "Depozito ayrıntılarını göster"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "Mevcut fonlar"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "İptal Edildi"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "Tahmini"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "Başarısız"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "Geçiş Yapılıyor"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "Ödendi"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "Bekliyor"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "Bilinmiyor"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "Son Depozito"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "Fonlarınızı ne zaman alacağınız hakkında daha fazla bilgi edinin"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "Bekleyen fonlar"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "Cihazdaki belgeler"; @@ -10530,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "Şu anda Avrupa Birliği (AB) gümrük kurallarını uygulayan ülkelere gönderim yaparken her öğeyi net bir şekilde açıklamanız gerekiyor. Örneğin, kıyafet gönderiyorsanız, açıklamanın kabul edilebilir olması için bunun ne tür bir kıyafet olduğunu (ör. erkek gömleği, kadın yeleği, erkek ceketi) belirtmeniz gerekiyor. Aksi takdirde gümrükte gönderimler gecikebilir veya kesintiye uğrayabilir."; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "her %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "her gün"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "her ay"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "her ayın %1$@. günü"; /* Placeholder for the site url textfield. @@ -10699,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "Ödemeleri kabul etmek için konum erişimi gereklidir."; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo, mağazanızın ortam kütüphanesi için video kaydederken ses kaydı için mikrofonunuzu kullanır."; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "Ürün görselleri için kameradan fotoğraf kaydetmek veya Ürünlerinize ya da destek biletlerinize fotoğraf veya video eklemek için."; @@ -10768,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "Elle para iadesi"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "manuel olarak, talep üzerine"; /* Menu option for taking an image or video with the device's camera. */ @@ -10804,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "Kart okuyucular"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "Bakiyeler yükleniyor..."; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 Sipariş tamamlandı"; @@ -10851,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "Ayarlar"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "Bakiyeler yükleniyor..."; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "Tap to Pay Hakkında"; @@ -10861,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "Tap To Pay"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "Tamam"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "Tamam"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo Payments Bakiyesi"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo Payments Bakiyesi"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo Payments Bakiyesi"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo Payments Bakiyesi"; /* Display label for a product's subscription period when it is a single month. */ "month" = "Ay"; @@ -10956,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "Gönderim Ekle"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "İptal Et"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "E-posta Adresini Girin"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "E-posta"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "Faturayı E-posta ile Gönder"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "Fatura e-posta gönderilirken hata oluştu. Lütfen tekrar deneyin."; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "Lütfen geçerli bir e-posta adresi girin."; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "Faturayı Müşteriye E-posta ile Gönderin"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "Gönderim Ekle"; @@ -11185,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "ödemeler, tap to pay, woocommerce, woo, şahsi ödemeler, şahsen ödemeler, ödeme alma, ödemeler, okuyucu, kart okuyucu, kart okuyucu sipariş etme"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "Ödeme ayrıntılarını gizle"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "Ödeme ayrıntılarını göster"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "Mevcut fonlar"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "Son Ödeme"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "Fonlarınızı ne zaman alacağınız hakkında daha fazla bilgi edinin"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "İptal Edildi"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "Tahmini"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "Başarısız"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "Geçiş Yapılıyor"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "Ödendi"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "Bekliyor"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "Bilinmiyor"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "Bekleyen fonlar"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "paket sona erdi"; @@ -11245,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "Başka bir ödeme yöntemi dene"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "Ödemeyi tekrar dene"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "Siparişi düzenle"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "Ödeme hazırlığı hatası"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "Ödemeyi tekrar dene"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "Lütfen bekleyin..."; @@ -11603,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "Yeni sipariş"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "Fatura"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "Ara Toplam"; @@ -12703,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "Hay aksi, bir yanlışlık oldu. Lütfen yeniden deneyin!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "Hesabınız yoksa oluşturmak için bu e-posta adresi kullanılır."; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "Bu kullanıcı adına bağlı bir WordPress.com hesabı bulunamadı. Yeni hesap oluşturmak için e-posta adresi girebilirsiniz."; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "İptal Et"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "Oturum Aç"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "E-posta adresinizin doğru olduğundan emin olun ve istenmeyen posta klasörünüzü iki kez kontrol edin."; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/zh-Hans.lproj/InfoPlist.strings b/WooCommerce/Resources/zh-Hans.lproj/InfoPlist.strings index 4fa91bb1e48..8d841ede9bc 100644 --- a/WooCommerce/Resources/zh-Hans.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/zh-Hans.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ 用于拍摄照片或视频来添加到您的产品中,扫描产品 SKU 的条形码或支持工单。 NSLocationWhenInUseUsageDescription 需要位置访问权限才能接受付款。 + NSMicrophoneUsageDescription + Woo 使用麦克风,以便您在为商店的媒体库录制视频时捕捉音频。 NSPhotoLibraryUsageDescription 用于保存相机中的照片以当作产品图像,或者将照片或视频添加到您的产品或支持工单。 OpenOrdersAction.Title diff --git a/WooCommerce/Resources/zh-Hans.lproj/Localizable.strings b/WooCommerce/Resources/zh-Hans.lproj/Localizable.strings index eaa08c2dffc..e39e472463a 100644 --- a/WooCommerce/Resources/zh-Hans.lproj/Localizable.strings +++ b/WooCommerce/Resources/zh-Hans.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-12 09:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-26 09:54:05+0000 */ /* Plural-Forms: nplurals=1; plural=0; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: zh_CN */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic 系列"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "%1$@ 手动存入可用资金。"; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "可用资金将 %1$@ 自动支付。"; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "根据要求手动存入可用资金。"; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "可用资金将根据要求手动支付。"; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "平均订单价值"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "自定义行 %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "自定义包裹"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "更新订单 #%1$d 时出错"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "预估 %1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "完整功能列表"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "资金将在等待 %1$d 天后到账。"; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "库存"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "缅甸"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "不适用"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "无法检索收据。"; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "很抱歉,您只能在网页上编辑此订单,因为它使用的是 %1$@,而您站点的货币是 %2$@。"; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "每 %1$@ %2$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "配送地址"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "SKU"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "取消"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "CTA 文本"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "号召性用语"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Blaze 广告的描述文本"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "编辑广告"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "CTA 文本不能超过 %1$d 个字符"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "描述不能为空"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "正在扫描读卡器"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "收据已发送至 %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "电子邮件收据"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "付款成功"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "打印收据"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "收据已发送至 %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "保存收据并继续"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "在订单备注中记录交易详情"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "注册费用:%1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "隐藏存款详情"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "显示存款详情"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "可用资金"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "已取消"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "预估价格"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "失败"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "运输中"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "已支付"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "待处理"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "未知"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "上次存款"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "进一步了解收到资金的时间"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "待处理资金"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "设备上的文档"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "现在,当运输到遵守欧盟 (EU) 海关规定的国家\/地区时,您需要清楚地描述每一件商品。 例如,假设您发送衣服,您的描述中需要指出是哪种类型的衣服(例如,男士衬衫、女孩背心、男孩夹克)才可被接受。 否则货物可能会在海关滞留或被扣留。"; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "每 %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "每天"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "每月"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "每月 %1$@ 日"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "需要位置访问权限才能接受付款。"; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "Woo 使用麦克风,以便您在为商店的媒体库录制视频时捕捉音频。"; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "用于保存相机中的照片以当作产品图像,或者将照片或视频添加到您的产品或支持工单。"; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "手动退款"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "根据要求手动操作"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "读卡器"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "正在加载余额…"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 订单已完成"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "设置"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "正在加载余额…"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "关于“点按付款”功能"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "“点按付款”功能"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "完成"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "完成"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo Payments 余额"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo Payments 余额"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo Payments 余额"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo Payments 余额"; /* Display label for a product's subscription period when it is a single month. */ "month" = "月"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "添加配送信息"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "取消"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "输入电子邮件地址"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "电子邮件地址"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "电子邮件收据"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "发送电子邮件收据时出错。 请重试。"; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "请输入有效的电子邮件地址。"; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "通过电子邮件向客户发送收据"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "添加配送信息"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "付款, 点按付款, woocommerce, woo, 现场付款, 收款, 付款, 读卡器, 读卡器, 订单读卡器"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "隐藏付款详细信息"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "显示付款详细信息"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "可用资金"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "最近一次付款"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "进一步了解收到资金的时间"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "已取消"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "预估价格"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "失败"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "运输中"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "已支付"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "待处理"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "未知"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "待处理资金"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "套餐已结束"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "尝试另一种付款方式"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "再次尝试付款"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "编辑订单"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "付款准备工作出错"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "再次尝试付款"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "请稍候…"; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "新订单"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "收据"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "小计"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "糟糕,出错了。 请重试!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "如果您没有账户,我们将使用此电子邮件地址创建一个。"; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "我们找不到与此用户名相关联的 WordPress.com 账户。 您可以输入电子邮件地址,以创建新账户。"; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "取消"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "登录"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "确保您的电子邮件地址正确无误,并仔细检查您的垃圾邮件文件夹。"; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/Resources/zh-Hant.lproj/InfoPlist.strings b/WooCommerce/Resources/zh-Hant.lproj/InfoPlist.strings index 02abeebc243..9beed8f6aaa 100644 --- a/WooCommerce/Resources/zh-Hant.lproj/InfoPlist.strings +++ b/WooCommerce/Resources/zh-Hant.lproj/InfoPlist.strings @@ -17,6 +17,8 @@ 用於拍攝要新增至商品的照片或影片、掃描商品貨號條碼,或用於支援票券。 NSLocationWhenInUseUsageDescription 須提供定位權限才能接受付款。 + NSMicrophoneUsageDescription + 錄製商店媒體庫的影片時,Woo 會使用麥克風錄音。 NSPhotoLibraryUsageDescription 用於儲存相機照片做為商品圖片,或為商品或支援票證新增照片或影片。 OpenOrdersAction.Title diff --git a/WooCommerce/Resources/zh-Hant.lproj/Localizable.strings b/WooCommerce/Resources/zh-Hant.lproj/Localizable.strings index 7fe31d2958d..faccf50a308 100644 --- a/WooCommerce/Resources/zh-Hant.lproj/Localizable.strings +++ b/WooCommerce/Resources/zh-Hant.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* Translation-Revision-Date: 2024-11-13 11:54:04+0000 */ +/* Translation-Revision-Date: 2024-11-26 13:54:03+0000 */ /* Plural-Forms: nplurals=1; plural=0; */ /* Generator: GlotPress/2.4.0-alpha */ /* Language: zh_TW */ @@ -950,11 +950,11 @@ which should be translated separately and considered part of this sentence. */ /* Title of a button linking to the Automattic website */ "Automattic family" = "Automattic 品牌系列"; -/* Hint showing the deposit schedule for a merchant's WooPayments account. e.g. Available funds are deposited automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ -"Available funds are deposited automatically, %1$@." = "可用款項將自動於%1$@存入。"; +/* Hint showing the payout schedule for a merchant's WooPayments account. e.g. Available funds are paid out automatically, every Wednesday. %1$@ will be replaced with a translated frequency description, e.g. 'every day' or 'monthly on the 28th' */ +"Available funds are paid out automatically, %1$@." = "%1$@會自動發放可用款項。"; -/* Hint showing the deposit schedule for a merchant's WooPayments account with a manual schedule. */ -"Available funds are deposited manually, on request." = "可用款項將依要求手動存入。"; +/* Hint showing the payout schedule for a merchant's WooPayments account with a manual schedule. */ +"Available funds are paid out manually, on request." = "可用款項將依要求手動發放。"; /* Label for average value of orders in the Analytics Hub */ "Average Order Value" = "平均訂單金額"; @@ -2073,7 +2073,8 @@ which should be translated separately and considered part of this sentence. */ /* Custom line index in Customs Form of Shipping Label flow */ "Custom Line %1$d" = "自訂行 %1$d"; -/* Custom Package menu in Shipping Label Add New Package flow */ +/* Custom Package menu in Shipping Label Add New Package flow + Label used to mark a custom package in list of saved packages */ "Custom Package" = "自訂包裹"; /* Label for one of the filters in order date range @@ -2823,7 +2824,7 @@ which should be translated separately and considered part of this sentence. */ /* Notice title when marking an order as completed via a swipe action fails. Parameter: Order Number */ "Error updating Order #%1$d" = "更新訂單 (編號:%1$d) 時發生錯誤"; -/* String indicating that a deposit date is an estimate. Shown on whe WooPayments Deposits View. %1$@ will be replaced with a locale-appropriate date string. */ +/* String indicating that a payout date is an estimate. Shown on whe WooPayments Payouts View. %1$@ will be replaced with a locale-appropriate date string. */ "Est. %1$@" = "預計存入日期:%1$@"; /* Estimated setup time title text shown on the Woo payments setup instructions screen. */ @@ -3110,7 +3111,7 @@ which should be translated separately and considered part of this sentence. */ /* Title of the view which shows the full feature list for paid plans. */ "Full Feature List" = "完整功能清單"; -/* Hint regarding available/pending balances shown in the WooPayments Deposits View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ +/* Hint regarding available/pending balances shown in the WooPayments Payouts View%1$d will be replaced by the number of days balances pend, and will be one of 2/4/5/7. */ "Funds become available after pending for %1$d days." = "款項待確認 %1$d 天後,現在已可提供。"; /* Format of the Global Unique Identifier on the Inventory Settings row */ @@ -3701,6 +3702,7 @@ which should be translated separately and considered part of this sentence. */ /* Product Inventory Settings navigation title Title of the Inventory Settings row on Product main screen + Title of the product form bottom sheet action for editing external inventory. Title of the product form bottom sheet action for editing inventory settings. */ "Inventory" = "庫存"; @@ -4408,7 +4410,7 @@ which should be translated separately and considered part of this sentence. */ /* Country option for a site address. */ "Myanmar" = "緬甸"; -/* String used when there's no date available for a deposit type on the WooPayments Deposits View. */ +/* String used when there's no date available for a payout type on the WooPayments Payouts View. */ "N\/A" = "N\/A"; /* Name text field placeholder @@ -4897,6 +4899,9 @@ which should be translated separately and considered part of this sentence. */ /* Notice that appears when no receipt can be retrieved upon tapping on 'See receipt' in the Order Details view. */ "OrderDetailsViewModel.displayReceiptRetrievalErrorNotice.notice" = "無法擷取收據。"; +/* Title for notice that's shown when trying to edit an order that's in a different currency. This action isn't supported in the app. Placeholders: %1$@ is the order currency code (e.g. USD), %2$@ is the site currency code (e.g. GBP.) */ +"OrderDetailsViewModel.editingOrderWithCurrencyConflictNotice.title" = "很抱歉,你只能在網頁上編輯此訂單,這是因為此訂單使用「%1$@」,而貴網站採用的幣值為:%2$@。"; + /* Description of the subscription billing interval for a product. Reads like: 'Every 2 months'. */ "OrderSubscriptionTableViewCellViewModel.billingInterval" = "每 %2$@ %1$@"; @@ -6028,11 +6033,8 @@ which should be translated separately and considered part of this sentence. */ /* Details section title in the Edit Address Form */ "SHIPPING ADDRESS" = "運送地址"; -/* Edit Product SKU navigation title - Title of the cell in Product Inventory Settings > SKU - Title of the product form bottom sheet action for editing short description. - Title of the product search filter to search for products that match the SKU. - Title of the SKU row on Product main screen */ +/* Title of the cell in Product Inventory Settings > SKU + Title of the product search filter to search for products that match the SKU. */ "SKU" = "貨號"; /* The message of the alert when there is an error updating the product SKU */ @@ -9674,6 +9676,12 @@ which should be translated separately and considered part of this sentence. */ /* Cancel button in the Blaze Edit Ad screen. */ "blazeEditAdView.cancel" = "取消"; +/* Placeholder for CTA Text field in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.placeholder" = "行動呼籲文字"; + +/* CTA Text title text in the Blaze Edit Ad screen. */ +"blazeEditAdView.ctaText.title" = "行動呼籲"; + /* Placeholder for Description text field in the Blaze Edit Ad screen. */ "blazeEditAdView.description.placeholder" = "Blaze 廣告的說明文字"; @@ -9704,6 +9712,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the Blaze Edit Ad screen. */ "blazeEditAdView.title" = "編輯廣告"; +/* Edit Blaze Ad screen: Error message if CTA Text exceeds the character limit. */ +"blazeEditAdViewModel.ctaText.lengthExceedsLimit" = "行動呼籲文字不能超過 %1$d 個字元"; + /* Edit Blaze Ad screen: Error message if Description field is empty. */ "blazeEditAdViewModel.description.emptyError" = "說明不得空白"; @@ -9978,6 +9989,24 @@ which should be translated separately and considered part of this sentence. */ /* Title label for modal dialog that appears when searching for a card reader */ "cardPresent.modalScanningForReader.title" = "掃瞄讀卡機"; +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.error.receiptMessage" = "收據已傳送至 %1$@"; + +/* Button to email receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.emailReceipt" = "電子郵件收據"; + +/* Label informing users that the payment succeeded. Presented to users when a payment is collected */ +"cardPresentPaymentsModal.success.paymentSuccessful" = "付款成功"; + +/* Button to print receipts. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.printReceipt" = "列印收據"; + +/* Message informing the user that a receipt has been sent to their email address. %1$@ is the email address */ +"cardPresentPaymentsModal.success.receiptMessage" = "收據已傳送至 %1$@"; + +/* Button when the user does not want to print or email receipt. Presented to users after a payment has been successfully collected */ +"cardPresentPaymentsModal.success.saveReceiptAndContinue" = "儲存收據並繼續"; + /* Title for the toggle that specifies whether to add a note to the order with the change data. */ "cashPaymentTenderView.addNoteToggle.title" = "在訂單備註中記錄交易詳細資料"; @@ -10461,45 +10490,6 @@ which should be translated separately and considered part of this sentence. */ /* Format of the sign-up fee for a subscription product on the Price Settings row. Reads like: 'Sign-up fee: $0.99'. */ "defaultProductFormTableViewModel.subscriptionSignupFeeFormat" = "註冊費:%1$@"; -/* Accessibility label for the collapse chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.hide" = "隱藏存款詳細資料"; - -/* Accessibility label for the expand chevron on the Deposit summary */ -"deposits.currency.overview.accessibility.show" = "顯示存款詳細資料"; - -/* Title for available funds overview in WooPayments Deposits view. This shows the balance which can be paid out. */ -"deposits.currency.overview.availableFunds" = "可用款項"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.canceled.title" = "已取消"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.estimated.title" = "估計值"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.failed.title" = "失敗"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.inTransit.title" = "運送中"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.paid.title" = "已付費"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.pending.title" = "待確認"; - -/* A status for a deposit, shown in a small badge view */ -"deposits.currency.overview.depositTable.status.unknown.title" = "未知"; - -/* Section header for the last deposit in the WooPayments Deposits overview */ -"deposits.currency.overview.lastDeposit" = "上次存款"; - -/* Button text to view more about payment schedules on the WooPayments Deposits View. */ -"deposits.currency.overview.learnMore" = "深入瞭解何時會收到款項"; - -/* Title for pending funds overview in WooPayments Deposits view. This shows the balance which will be made available for pay out later. */ -"deposits.currency.overview.pendingFunds" = "待確認款項"; - /* Title of the downloadable file bottom sheet action for adding document from device. */ "downloadableFileSource.deviceDocument" = "裝置上的文件"; @@ -10542,16 +10532,16 @@ which should be translated separately and considered part of this sentence. */ /* The EU notice banner content describing how the shipping customs shall be configured */ "eu_shipping_instructions_info" = "如需運送至遵循歐盟 (EU) 海關規定的國家\/地區,現在必須清楚描述每件商品。 舉例來說,若要寄送衣服,你必須指明衣服種類 (例如,男性襯衫、女童背心、男童外套),海關才會採納相關資訊。 否則貨件可能會在海關階段延誤或中斷。"; -/* every {dayname}, shown in a sentence like 'Available funds are deposited automatically, every Wednesday' %1$@ will be replaced with the localized day name */ +/* every {dayname}, shown in a sentence like 'Available funds are paid out automatically, every Wednesday' %1$@ will be replaced with the localized day name */ "every %1$@" = "每 %1$@"; -/* Shown in a sentence like 'Available funds are deposited automatically, every day' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every day' */ "every day" = "每天"; -/* Shown in a sentence like 'Available funds are deposited automatically every month. */ +/* Shown in a sentence like 'Available funds are paid out automatically every month. */ "every month" = "每月"; -/* Shown in a sentence like 'Available funds are deposited automatically, every month on the 15th' */ +/* Shown in a sentence like 'Available funds are paid out automatically, every month on the 15th' */ "every month on the %1$@" = "可於每月 %1$@存入"; /* Placeholder for the site url textfield. @@ -10711,6 +10701,9 @@ which should be translated separately and considered part of this sentence. */ /* A message that tells the user why the app is requesting access to the user’s location information while the app is running in the foreground. */ "infoplist.NSLocationWhenInUseUsageDescription" = "須提供定位權限才能接受付款。"; +/* A message that tells the user why the app needs access to Microphone. */ +"infoplist.NSMicrophoneUsageDescription" = "錄製商店媒體庫的影片時,Woo 會使用麥克風錄音。"; + /* A message that tells the user why the app is requesting access to the user’s photo library. */ "infoplist.NSPhotoLibraryUsageDescription" = "用於儲存相機照片做為商品圖片,或為商品或支援票證新增照片或影片。"; @@ -10780,7 +10773,7 @@ which should be translated separately and considered part of this sentence. */ /* A manual refund is one where the store owner has given the purchaser alternative funds (cash, check, ACH) instead of using the payment gateway to create a refund (credit card or debit card was refunded) */ "manual refund" = "手動退款"; -/* on request (lower case), shown in a sentence like 'Deposit schedule: manual, on request' */ +/* on request (lower case), shown in a sentence like 'Payout schedule: manual, on request' */ "manually, on request" = "依要求手動存入"; /* Menu option for taking an image or video with the device's camera. */ @@ -10816,9 +10809,6 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to card readers inside In-Person Payments settings */ "menu.payments.cardReader.section.title" = "讀卡機"; -/* An accessibility label used when the balances are loading on the payments menu */ -"menu.payments.depositSummary.loading.accessibilityLabel" = "正在載入餘額…"; - /* Notice text after completing a payment order from In-Person Payments in the Menu */ "menu.payments.inPersonPayments.collectPayment.notice.orderCompleted" = "🎉 已完成訂單"; @@ -10863,6 +10853,9 @@ which should be translated separately and considered part of this sentence. */ /* Title for the section related to changing payment settings inside the In-Person Payments menu */ "menu.payments.paymentSettings.section.title" = "設定"; +/* An accessibility label used when the balances are loading on the payments menu */ +"menu.payments.payoutSummary.loading.accessibilityLabel" = "正在載入餘額…"; + /* Navigates to the About Tap to Pay on iPhone screen, which explains the capabilities and limits of Tap to Pay on iPhone, relevant to the store territory. */ "menu.payments.tapToPay.about.row.title" = "關於「卡緊收」"; @@ -10873,13 +10866,13 @@ which should be translated separately and considered part of this sentence. */ "menu.payments.tapToPay.section.title" = "卡緊收"; /* Title for a done button in the navigation bar */ -"menu.payments.wooPaymentsDeposits.navigation.done.button.title" = "完成"; +"menu.payments.wooPaymentsPayouts.navigation.done.button.title" = "完成"; -/* Title for the row related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.row.title" = "Woo Payments 餘額"; +/* Title for the row related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.row.title" = "Woo Payments 餘額"; -/* Title for the section related to Woo Payments Deposits/Balances. */ -"menu.payments.wooPaymentsDeposits.section.title" = "Woo Payments 餘額"; +/* Title for the section related to Woo Payments Payouts/Balances. */ +"menu.payments.wooPaymentsPayouts.section.title" = "Woo Payments 餘額"; /* Display label for a product's subscription period when it is a single month. */ "month" = "月"; @@ -10968,6 +10961,27 @@ which should be translated separately and considered part of this sentence. */ /* Title text of the button that adds shipping line when creating a new order */ "order.form.shipping.add.button.title" = "新增運送"; +/* Text for the cancel button to dismiss Send Receipt to Customer screen */ +"order.receiptEmailView.cancel" = "取消"; + +/* Email field placeholder */ +"order.receiptEmailView.emailFieldHint" = "輸入電子郵件地址"; + +/* Email text field title */ +"order.receiptEmailView.emailFieldTitle" = "電子郵件地址"; + +/* Title for the button to send the receipt to the customer */ +"order.receiptEmailView.emailReceipt" = "電子郵件收據"; + +/* An error that is shown when sending email receipt fails. */ +"order.receiptEmailView.errorNotice" = "傳送電子郵件收據時發生錯誤, 請再試一次。"; + +/* Notice text when the merchant enters an invalid email */ +"order.receiptEmailView.invalidEmailError" = "請輸入有效的電子郵件地址。"; + +/* Title for the screen to update customer email address and send receipt */ +"order.receiptEmailView.title" = "傳送電子郵件收據給顧客"; + /* Button to add a shipping line to the order during order creation */ "order.shippingLineDetails.addShipping" = "新增運送方式"; @@ -11197,6 +11211,45 @@ which should be translated separately and considered part of this sentence. */ /* This is a comma separated list of keywords used for spotlight indexing of the 'Payments' screen. */ "payments, tap to pay, woocommerce, woo, in-person payments, in person paymentscollect payment, payments, reader, card reader, order card reader" = "付款、點選支付、woocommerce、woo、親自收款、收取款項、款項、讀卡機、信用卡讀卡機、訂購讀卡機"; +/* Accessibility label for the collapse chevron on the Payout summary */ +"payouts.currency.overview.accessibility.hide" = "隱藏款項詳細資料"; + +/* Accessibility label for the expand chevron on the Payout summary */ +"payouts.currency.overview.accessibility.show" = "顯示款項詳細資料"; + +/* Title for available funds overview in WooPayments Payouts view. This shows the balance which can be paid out. */ +"payouts.currency.overview.availableFunds" = "可用款項"; + +/* Section header for the last payout in the WooPayments Payouts overview */ +"payouts.currency.overview.lastPayout" = "上次款項"; + +/* Button text to view more about payment schedules on the WooPayments Payouts View. */ +"payouts.currency.overview.learnMore" = "深入了解何時會收到款項"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.canceled.title" = "已取消"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.estimated.title" = "估計值"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.failed.title" = "失敗"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.inTransit.title" = "運送中"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.paid.title" = "已付費"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.pending.title" = "待確認"; + +/* A status for a payout, shown in a small badge view */ +"payouts.currency.overview.payoutTable.status.unknown.title" = "不明"; + +/* Title for pending funds overview in WooPayments Payouts view. This shows the balance which will be made available for pay out later. */ +"payouts.currency.overview.pendingFunds" = "待確認款項"; + /* Shown with a 'Current:' label, but when we don't know what the plan that ended was */ "plan ended" = "方案已結束"; @@ -11257,13 +11310,15 @@ which should be translated separately and considered part of this sentence. */ /* Title of the button used on a card payment error from the Point of Sale Checkout to go back and try another payment method. */ "pointOfSale.cardPresent.paymentErrorNonRetryable.tryAnotherPaymentMethod.button.title" = "嘗試其他付款方式"; -/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout - Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ -"pointOfSale.cardPresent.paymentIntentCreationError.backToCheckout.button.title" = "再次嘗試付款"; +/* Button to come back to order editing when a card payment fails. Presented to users after payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.checkout.button.title" = "編輯訂單"; /* Error message. Presented to users after payment intent creation fails on the Point of Sale Checkout */ "pointOfSale.cardPresent.paymentIntentCreationError.title" = "付款準備作業錯誤"; +/* Button to try to collect a payment again. Presented to users after collecting a payment intention creation fails on the Point of Sale Checkout */ +"pointOfSale.cardPresent.paymentIntentCreationError.tryPaymentAgain.button.title" = "再次嘗試付款"; + /* Indicates to wait while payment is processing. Presented to users when payment collection starts */ "pointOfSale.cardPresent.paymentProcessing.message" = "請稍候…"; @@ -11615,6 +11670,9 @@ which should be translated separately and considered part of this sentence. */ /* Button title for new order button */ "pos.totalsView.newOrder" = "新訂單"; +/* Button title for the receipt button */ +"pos.totalsView.sendReceipt" = "收據"; + /* Title for subtotal amount field */ "pos.totalsView.subtotal" = "小計"; @@ -12715,12 +12773,21 @@ which should be translated separately and considered part of this sentence. */ /* Generic error on the 2FA login screen */ "wpCom2FALoginViewModel.unknownError" = "糟糕,出狀況了。 請再試一次!"; +/* Text hinting that an account will be created if the email is not associated with an existing account. */ +"wpComEmailLoginView.accountCreationHint" = "如果你沒有帳號,系統會使用此電子郵件地址建立帳號。"; + +/* Error message when the username is not found */ +"wpComEmailLoginViewModel.unknownUsername" = "我們找不到此使用者名稱所連結的 WordPress.com 帳號。 請輸入電子郵件地址建立新帳號。"; + /* Button to dismiss an error alert in the WPCom login flow */ "wpComLoginCoordinator.cancelButton" = "取消"; /* Title for the screens in the login flow */ "wpComLoginCoordinator.title" = "登入"; +/* Text hinting the user to ensure their email is correct and check their spam folder */ +"wpComMagicLinkView.emailConfirmationHint" = "請確定電子郵件地址是否正確,並再次查看垃圾郵件資料夾。"; + /* A clickable text link that willredirect the user to a website */ "www.usps.com\/hazmat" = "www.usps.com\/hazmat"; diff --git a/WooCommerce/StoreWidgets/Homescreen/StoreInfoView.swift b/WooCommerce/StoreWidgets/Homescreen/StoreInfoView.swift index 0bbe78651f8..2fb14693493 100644 --- a/WooCommerce/StoreWidgets/Homescreen/StoreInfoView.swift +++ b/WooCommerce/StoreWidgets/Homescreen/StoreInfoView.swift @@ -79,7 +79,7 @@ private struct StatsCard: View { Text(StoreInfoView.Localization.revenue) .statTitleStyle() - Text(entryData.revenue) + Text(entryData.revenueCompact) .statValueStyle() } @@ -132,7 +132,7 @@ private struct AccessibilityStatsCard: View { Text(StoreInfoView.Localization.revenue) .statTitleStyle() - Text(entryData.revenue) + Text(entryData.revenueCompact) .statValueStyle() } @@ -278,12 +278,14 @@ private extension UnableToFetchView { } // MARK: - Previews +#if DEBUG +import class WooFoundation.CurrencySettings struct StoreWidgets_Previews: PreviewProvider { static var exampleData = StoreInfoData(range: "Today", name: "Ernest Shop", - revenue: "$132.234", - revenueCompact: "$132", + revenue: StoreInfoFormatter.formattedAmountString(for: Decimal(123456789), with: CurrencySettings()), + revenueCompact: StoreInfoFormatter.formattedAmountCompactString(for: Decimal(123456789), with: CurrencySettings()), visitors: "67", orders: "23", conversion: "34%", @@ -307,3 +309,4 @@ struct StoreWidgets_Previews: PreviewProvider { .previewDisplayName("Unable to fetch data") } } +#endif diff --git a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoCircularWidget.swift b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoCircularWidget.swift index c466bad39c6..369e0a024bb 100644 --- a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoCircularWidget.swift +++ b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoCircularWidget.swift @@ -30,6 +30,7 @@ private struct StoreInfoCircularView: View { .fill(Color.black) Text(entryData.revenueCompact) } + .widgetBackground(backgroundView: Color(.brand)) } } @@ -45,13 +46,15 @@ private struct UnableToFetchView: View { } // MARK: - Previews +#if DEBUG +import class WooFoundation.CurrencySettings @available(iOSApplicationExtension 16.0, *) struct StoreInfoCircularWidget_Previews: PreviewProvider { static var exampleData = StoreInfoData(range: "Today", name: "Ernest Shop", - revenue: "$132.234", - revenueCompact: "$132", + revenue: StoreInfoFormatter.formattedAmountString(for: Decimal(123456789), with: CurrencySettings()), + revenueCompact: StoreInfoFormatter.formattedAmountCompactString(for: Decimal(123456789), with: CurrencySettings()), visitors: "67", orders: "23", conversion: "34%", @@ -66,3 +69,4 @@ struct StoreInfoCircularWidget_Previews: PreviewProvider { .previewDisplayName("Unable to fetch") } } +#endif diff --git a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoInlineWidget.swift b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoInlineWidget.swift index 09ce0f050b4..19878aef284 100644 --- a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoInlineWidget.swift +++ b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoInlineWidget.swift @@ -25,7 +25,7 @@ private struct StoreInfoInlineView: View { let entryData: StoreInfoData var body: some View { - Text(Localization.titleWithRevenue(entryData.revenue)) + Text(Localization.titleWithRevenue(entryData.revenueCompact)) .statValueStyle() } } @@ -60,13 +60,15 @@ private extension UnableToFetchView { } // MARK: - Previews +#if DEBUG +import class WooFoundation.CurrencySettings @available(iOSApplicationExtension 16.0, *) struct StoreInfoInlineWidget_Previews: PreviewProvider { static var exampleData = StoreInfoData(range: "Today", name: "Ernest Shop", - revenue: "$132.234", - revenueCompact: "$132", + revenue: StoreInfoFormatter.formattedAmountString(for: Decimal(123456789), with: CurrencySettings()), + revenueCompact: StoreInfoFormatter.formattedAmountCompactString(for: Decimal(123456789), with: CurrencySettings()), visitors: "67", orders: "23", conversion: "34%", @@ -81,3 +83,4 @@ struct StoreInfoInlineWidget_Previews: PreviewProvider { .previewDisplayName("Unable to fetch") } } +#endif diff --git a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoRectangularWidget.swift b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoRectangularWidget.swift index 004f6f1d9a4..53d47e94f39 100644 --- a/WooCommerce/StoreWidgets/Lockscreen/StoreInfoRectangularWidget.swift +++ b/WooCommerce/StoreWidgets/Lockscreen/StoreInfoRectangularWidget.swift @@ -29,10 +29,11 @@ private struct StoreInfoRectangularView: View { VStack(alignment: .leading) { Text(Localization.revenue) .font(.headline) - Text(entryData.revenue) + Text(entryData.revenueCompact) } Spacer() } + .widgetBackground(backgroundView: Color(.brand)) } } @@ -72,13 +73,15 @@ private extension UnableToFetchView { } // MARK: - Previews +#if DEBUG +import class WooFoundation.CurrencySettings @available(iOSApplicationExtension 16.0, *) struct StoreInfoRectangularWidget_Previews: PreviewProvider { static var exampleData = StoreInfoData(range: "Today", name: "Ernest Shop", - revenue: "$132.234", - revenueCompact: "$132", + revenue: StoreInfoFormatter.formattedAmountString(for: Decimal(123456789), with: CurrencySettings()), + revenueCompact: StoreInfoFormatter.formattedAmountCompactString(for: Decimal(123456789), with: CurrencySettings()), visitors: "67", orders: "23", conversion: "34%", @@ -93,3 +96,4 @@ struct StoreInfoRectangularWidget_Previews: PreviewProvider { .previewDisplayName("Unable to fetch") } } +#endif diff --git a/WooCommerce/UITestsFoundation/Screens/MyStore/MyStoreScreen.swift b/WooCommerce/UITestsFoundation/Screens/MyStore/MyStoreScreen.swift index 744c3710fdc..be567891f02 100644 --- a/WooCommerce/UITestsFoundation/Screens/MyStore/MyStoreScreen.swift +++ b/WooCommerce/UITestsFoundation/Screens/MyStore/MyStoreScreen.swift @@ -23,30 +23,31 @@ public final class MyStoreScreen: ScreenObject { return self } - func tapTimeframeTab(timeframeId: String) -> MyStoreScreen { - app.buttons[timeframeId].tap() + func tapTimeRangeOption(id: String) -> MyStoreScreen { + app.buttons["performance-time-range-menu"].tap() + app.buttons[id].tap() return self } @discardableResult public func goToThisWeekTab() -> MyStoreScreen { - return tapTimeframeTab(timeframeId: "period-data-thisWeek-tab") + return tapTimeRangeOption(id: "time-range-this-week") } @discardableResult public func goToThisMonthTab() -> MyStoreScreen { - return tapTimeframeTab(timeframeId: "period-data-thisMonth-tab") + return tapTimeRangeOption(id: "time-range-this-month") } @discardableResult public func goToThisYearTab() -> MyStoreScreen { - return tapTimeframeTab(timeframeId: "period-data-thisYear-tab") + return tapTimeRangeOption(id: "time-range-this-year") } func verifyStatsForTimeframeLoaded(timeframe: String) -> MyStoreScreen { - let textPredicate = NSPredicate(format: "label MATCHES %@", "Store revenue chart \(timeframe)") - XCTAssertTrue(app.images.containing(textPredicate).element.exists, "\(timeframe) chart not displayed") + let textPredicate = NSPredicate(format: "label MATCHES %@", "\(timeframe)") + XCTAssertTrue(app.staticTexts.containing(textPredicate).element.exists, "\(timeframe) chart not displayed") return self } @@ -73,7 +74,7 @@ public final class MyStoreScreen: ScreenObject { } public func tapChart() { - app.images["chart-image"].tap() + app.otherElements["store-stats-chart"].tap() } public func verifyRevenueUpdated(originalRevenue: String, updatedRevenue: String) { diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index c5ea27808bd..644b5ea6a49 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -22,10 +22,14 @@ /* Begin PBXBuildFile section */ 010C9A8F2C75C2BF00EBA228 /* Color+Inverted.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010C9A8E2C75C2BF00EBA228 /* Color+Inverted.swift */; }; + 011D396F2D09FCD200DB1445 /* CardPresentModalLocationRequired.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D396E2D09FCCB00DB1445 /* CardPresentModalLocationRequired.swift */; }; + 011D39712D0A324200DB1445 /* LocationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D39702D0A324100DB1445 /* LocationServiceTests.swift */; }; 011D7A332CEC877A0007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */; }; 011D7A352CEC87B70007C187 /* CardPresentModalErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */; }; 011DF3442C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */; }; 011DF3462C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */; }; + 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 */; }; @@ -48,14 +52,20 @@ 019130212CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */; }; 01929C342CEF6354006C79ED /* CardPresentModalErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */; }; 01929C362CEF6D6E006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */; }; + 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 */; }; 01ADC1362C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */; }; 01ADC1382C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */; }; + 01BB6C072D09DC560094D55B /* CardPresentModalLocationPreAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */; }; + 01BB6C0A2D09E9630094D55B /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BB6C092D09E9630094D55B /* LocationService.swift */; }; 01BD77442C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD77432C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift */; }; 01BD77462C58D0D000147191 /* PointOfSaleCardPresentPaymentSuccessMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD77452C58D0D000147191 /* PointOfSaleCardPresentPaymentSuccessMessageView.swift */; }; 01BD77482C58D19C00147191 /* PointOfSaleCardPresentPaymentCancelledOnReaderMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD77472C58D19C00147191 /* PointOfSaleCardPresentPaymentCancelledOnReaderMessageView.swift */; }; 01BD774A2C58D29700147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD77492C58D29700147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageView.swift */; }; 01BD774C2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01BD774B2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift */; }; 01D082402C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D0823F2C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift */; }; + 01F067ED2D0C5D59001C5805 /* MockLocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */; }; 01F42C162CE34AB8003D0A5A /* CardPresentModalBuiltInSuccessEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F42C152CE34AB3003D0A5A /* CardPresentModalBuiltInSuccessEmailSent.swift */; }; 01F42C182CE34AD2003D0A5A /* CardPresentModalSuccessEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F42C172CE34AD1003D0A5A /* CardPresentModalSuccessEmailSent.swift */; }; 01F579952C7DE709008BCA28 /* PointOfSaleCardPresentPaymentCaptureErrorMessageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F579942C7DE709008BCA28 /* PointOfSaleCardPresentPaymentCaptureErrorMessageViewModelTests.swift */; }; @@ -181,6 +191,7 @@ 0230B4D62C33454900F2F660 /* PointOfSaleCardPresentPaymentCaptureErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0230B4D52C33454900F2F660 /* PointOfSaleCardPresentPaymentCaptureErrorMessageView.swift */; }; 0230B4D82C3345DF00F2F660 /* PointOfSaleCardPresentPaymentCaptureFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0230B4D72C3345DF00F2F660 /* PointOfSaleCardPresentPaymentCaptureFailedView.swift */; }; 02312797277D4F650060E180 /* StoreStatsPeriodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02312796277D4F640060E180 /* StoreStatsPeriodViewModel.swift */; }; + 02335E492D13BA42000B6ECE /* AsyncPaginationTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02335E482D13BA42000B6ECE /* AsyncPaginationTracker.swift */; }; 023453F22579DA1A00A6BB20 /* ShippingLabelPrintingInstructionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023453F12579DA1A00A6BB20 /* ShippingLabelPrintingInstructionsViewController.swift */; }; 0234680A282CEA5F00CFC503 /* LegacyReceiptViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02346809282CEA5F00CFC503 /* LegacyReceiptViewModelTests.swift */; }; 0235354E2999D17A00BF77D3 /* DomainSettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0235354D2999D17A00BF77D3 /* DomainSettingsViewModelTests.swift */; }; @@ -315,7 +326,7 @@ 026826992BF59DA90036F959 /* Color+WooCommercePOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826982BF59DA80036F959 /* Color+WooCommercePOS.swift */; }; 026826AA2BF59DF70036F959 /* CartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A32BF59DF60036F959 /* CartView.swift */; }; 026826AB2BF59DF70036F959 /* ItemRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A22BF59DF60036F959 /* ItemRowView.swift */; }; - 026826AC2BF59DF70036F959 /* ItemCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A42BF59DF60036F959 /* ItemCardView.swift */; }; + 026826AC2BF59DF70036F959 /* SimpleProductCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A42BF59DF60036F959 /* SimpleProductCardView.swift */; }; 026826AD2BF59DF70036F959 /* PointOfSaleDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A52BF59DF60036F959 /* PointOfSaleDashboardView.swift */; }; 026826AF2BF59DF70036F959 /* PointOfSaleEntryPointView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826A72BF59DF70036F959 /* PointOfSaleEntryPointView.swift */; }; 026826B52BF59E330036F959 /* CardReaderConnectionStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026826B32BF59E320036F959 /* CardReaderConnectionStatusView.swift */; }; @@ -363,6 +374,9 @@ 0279F0E4252DC9670098D7DE /* ProductVariationLoadUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0279F0E3252DC9670098D7DE /* ProductVariationLoadUseCase.swift */; }; 027A2E142513124E00DA6ACB /* Keychain+Entries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027A2E132513124E00DA6ACB /* Keychain+Entries.swift */; }; 027A2E162513356100DA6ACB /* AppleIDCredentialChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027A2E152513356100DA6ACB /* AppleIDCredentialChecker.swift */; }; + 027ADB6E2D1BF5E3009608DB /* ParentProductCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027ADB6D2D1BF5E3009608DB /* ParentProductCardView.swift */; }; + 027ADB732D21812D009608DB /* POSItemImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027ADB722D21812D009608DB /* POSItemImageView.swift */; }; + 027ADB752D218A8D009608DB /* POSItemCardBorderStylesModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027ADB742D218A8D009608DB /* POSItemCardBorderStylesModifier.swift */; }; 027B8BB823FE0CB30040944E /* DefaultProductUIImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027B8BB723FE0CB30040944E /* DefaultProductUIImageLoader.swift */; }; 027B8BBD23FE0DE10040944E /* ProductImageActionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027B8BBC23FE0DE10040944E /* ProductImageActionHandlerTests.swift */; }; 027B8BBF23FE0F850040944E /* MockMediaStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027B8BBE23FE0F850040944E /* MockMediaStoresManager.swift */; }; @@ -415,6 +429,9 @@ 029106C22BE34A8600C2248B /* CollapsibleCustomerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029106C12BE34A8600C2248B /* CollapsibleCustomerCard.swift */; }; 029106C42BE34AA900C2248B /* CollapsibleCustomerCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029106C32BE34AA900C2248B /* CollapsibleCustomerCardViewModel.swift */; }; 02913E9523A774C500707A0C /* UnitInputFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02913E9423A774C500707A0C /* UnitInputFormatter.swift */; }; + 029149782D26658A00F7B3B3 /* VariationCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029149772D26658A00F7B3B3 /* VariationCardView.swift */; }; + 0291497B2D2682FF00F7B3B3 /* ItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0291497A2D2682FF00F7B3B3 /* ItemList.swift */; }; + 0291497D2D26CB2500F7B3B3 /* ChildItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0291497C2D26CB2500F7B3B3 /* ChildItemList.swift */; }; 0294F8AB25E8A12C005B537A /* WooTabNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0294F8AA25E8A12C005B537A /* WooTabNavigationController.swift */; }; 02952B5127808B08008E9BA3 /* StoreStatsPeriodViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02952B5027808B08008E9BA3 /* StoreStatsPeriodViewModelTests.swift */; }; 0295355B245ADF8100BDC42B /* FilterType+Products.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295355A245ADF8100BDC42B /* FilterType+Products.swift */; }; @@ -790,6 +807,7 @@ 203163BB2C1C5F72001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdatePostalCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203163BA2C1C5F72001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdatePostalCodeView.swift */; }; 203163BD2C1C9602001C96DA /* PointOfSaleCardPresentPaymentAlertType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203163BC2C1C9602001C96DA /* PointOfSaleCardPresentPaymentAlertType.swift */; }; 203A5C312AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */; }; + 203AB2A82D01B988001D989C /* OrderCustomAmountsSectionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 203AB2A72D01B97D001D989C /* OrderCustomAmountsSectionViewModelTests.swift */; }; 2044158D2CE4DB480070BF54 /* PointOfSaleOrderStage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2044158C2CE4DB480070BF54 /* PointOfSaleOrderStage.swift */; }; 2044158F2CE6181E0070BF54 /* PointOfSaleOrderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2044158E2CE6181E0070BF54 /* PointOfSaleOrderState.swift */; }; 204415912CE622BA0070BF54 /* PointOfSaleOrderTotals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 204415902CE622BA0070BF54 /* PointOfSaleOrderTotals.swift */; }; @@ -870,6 +888,7 @@ 20D3D4332C65E59B004CE6E3 /* OrdersRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D3D4322C65E59B004CE6E3 /* OrdersRoute.swift */; }; 20D3D4352C65E640004CE6E3 /* OrdersDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D3D4342C65E640004CE6E3 /* OrdersDestination.swift */; }; 20D3D4372C65EF72004CE6E3 /* OrdersRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D3D4362C65EF72004CE6E3 /* OrdersRouteTests.swift */; }; + 20D4AE012D133B43004555B2 /* ItemsStackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D4AE002D133B43004555B2 /* ItemsStackState.swift */; }; 20D5CB512AFCF856009A39C3 /* PaymentsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D5CB502AFCF856009A39C3 /* PaymentsRow.swift */; }; 20D5CB532AFCF8E7009A39C3 /* PaymentsToggleRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D5CB522AFCF8E7009A39C3 /* PaymentsToggleRow.swift */; }; 20D920EA2CEF86520023B089 /* PointOfSaleErrorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D920E92CEF86520023B089 /* PointOfSaleErrorState.swift */; }; @@ -877,6 +896,8 @@ 20DB185B2CF5D9220018D3E1 /* MockPointOfSaleOrderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DB185A2CF5D9220018D3E1 /* MockPointOfSaleOrderController.swift */; }; 20DB185D2CF5E7630018D3E1 /* PointOfSaleOrderControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DB185C2CF5E7560018D3E1 /* PointOfSaleOrderControllerTests.swift */; }; 20E188842AD059A50053E945 /* AboutTapToPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20E188832AD059A50053E945 /* AboutTapToPayView.swift */; }; + 20F7B12D2D12C7B900C08193 /* ItemsContainerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F7B12C2D12C7B900C08193 /* ItemsContainerState.swift */; }; + 20F7B12F2D12CBE700C08193 /* ItemsViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20F7B12E2D12CBE700C08193 /* ItemsViewState.swift */; }; 20FA73882CDCC3A900554BE3 /* OrderDetailsSyncStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FA73872CDCC3A900554BE3 /* OrderDetailsSyncStateController.swift */; }; 20FCBCDD2CE223340082DCA3 /* PointOfSaleAggregateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FCBCDC2CE223340082DCA3 /* PointOfSaleAggregateModel.swift */; }; 20FCBCDF2CE241810082DCA3 /* PointOfSaleAggregateModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20FCBCDE2CE241810082DCA3 /* PointOfSaleAggregateModelTests.swift */; }; @@ -1517,6 +1538,7 @@ 57F2C6CD246DECC10074063B /* SummaryTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */; }; 57F42E40253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */; }; 581D5052274AA2480089B6AD /* View+AutofocusTextModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 581D5051274AA2480089B6AD /* View+AutofocusTextModifier.swift */; }; + 6801E4172D0FFF0300F9DF46 /* MockReceiptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6801E4162D0FFF0100F9DF46 /* MockReceiptService.swift */; }; 680BA59A2A4C377900F5559D /* UpgradeViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680BA5992A4C377900F5559D /* UpgradeViewState.swift */; }; 680E36B52BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 680E36B42BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib */; }; 680E36B72BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 680E36B62BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift */; }; @@ -1529,7 +1551,7 @@ 6837631C2C2E847D00AD51D0 /* CartViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6837631B2C2E847D00AD51D0 /* CartViewHelper.swift */; }; 683988A72C7D82E70084B85A /* POSHeaderLayoutConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683988A62C7D82E60084B85A /* POSHeaderLayoutConstants.swift */; }; 683AA9D62A303CB70099F7BA /* UpgradesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683AA9D52A303CB70099F7BA /* UpgradesViewModelTests.swift */; }; - 683AC4AC2CEF019A00FF0A5E /* POSSendReceiptModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683AC4AB2CEF019700FF0A5E /* POSSendReceiptModalView.swift */; }; + 683AC4AC2CEF019A00FF0A5E /* POSSendReceiptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683AC4AB2CEF019700FF0A5E /* POSSendReceiptView.swift */; }; 683DF5FF2C6AF46500A5CDC6 /* POSHeaderTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683DF5FE2C6AF46500A5CDC6 /* POSHeaderTitleView.swift */; }; 684AB83A2870677F003DFDD1 /* CardReaderManualsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684AB8392870677F003DFDD1 /* CardReaderManualsView.swift */; }; 684AB83C2873DF04003DFDD1 /* CardReaderManualsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684AB83B2873DF04003DFDD1 /* CardReaderManualsViewModel.swift */; }; @@ -1554,10 +1576,12 @@ 6885E2CC2C32B14B004C8D70 /* TotalsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */; }; 6888A2C82A668D650026F5C0 /* FullFeatureListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */; }; 6888A2CA2A66C42C0026F5C0 /* FullFeatureListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6888A2C92A66C42C0026F5C0 /* FullFeatureListViewModel.swift */; }; + 68A345642D029E12002EE324 /* PaymentButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A345632D029E09002EE324 /* PaymentButtons.swift */; }; 68A38DF52B293B030090C263 /* MockProductListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A38DF42B293B030090C263 /* MockProductListViewModel.swift */; }; 68A5221B2BA1804900A6A584 /* PluginDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A5221A2BA1804900A6A584 /* PluginDetailsViewModelTests.swift */; }; 68A905012ACCFC13004C71D3 /* CollapsibleProductCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A905002ACCFC13004C71D3 /* CollapsibleProductCard.swift */; }; 68AC9D292ACE598B0042F784 /* ProductImageThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68AC9D282ACE598B0042F784 /* ProductImageThumbnail.swift */; }; + 68AF3C3B2D01481C006F1ED2 /* POSReceiptEligibilityBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68AF3C3A2D01481A006F1ED2 /* POSReceiptEligibilityBanner.swift */; }; 68B6F22B2ADE7ED500D171FC /* TooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B6F22A2ADE7ED500D171FC /* TooltipView.swift */; }; 68C31B712A8617C500AE5C5A /* NewNoteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C31B702A8617C500AE5C5A /* NewNoteViewModel.swift */; }; 68C53CBE2C1FE59B00C6D80B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C53CBD2C1FE59B00C6D80B /* ItemListView.swift */; }; @@ -1569,6 +1593,7 @@ 68D8FBD12BFEF9C700477C42 /* TotalsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D8FBD02BFEF9C700477C42 /* TotalsView.swift */; }; 68DF5A8D2CB38EEA000154C9 /* EditableOrderCouponLineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68DF5A8C2CB38EEA000154C9 /* EditableOrderCouponLineViewModel.swift */; }; 68DF5A8F2CB38F20000154C9 /* OrderCouponSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68DF5A8E2CB38F20000154C9 /* OrderCouponSectionView.swift */; }; + 68E141DB2D13107400A70D5B /* PointOfSaleCollectCashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E141DA2D13107200A70D5B /* PointOfSaleCollectCashView.swift */; }; 68E4E8B52C0EF39D00CFA0C3 /* PreviewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E4E8B42C0EF39D00CFA0C3 /* PreviewHelpers.swift */; }; 68E6749F2A4DA01C0034BA1E /* WooWPComPlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E6749E2A4DA01C0034BA1E /* WooWPComPlan.swift */; }; 68E674A12A4DA0B30034BA1E /* InAppPurchasesError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E674A02A4DA0B30034BA1E /* InAppPurchasesError.swift */; }; @@ -1675,7 +1700,7 @@ 80A6430E2A026D0800F65C0C /* complete_cash_simple_payment.json in Resources */ = {isa = PBXBuildFile; fileRef = 80A6430D2A026D0800F65C0C /* complete_cash_simple_payment.json */; }; 80A643122A0270F100F65C0C /* orders_complete_simple_payment.json in Resources */ = {isa = PBXBuildFile; fileRef = 80A643112A0270F100F65C0C /* orders_complete_simple_payment.json */; }; 80AD2CA22782B4EB00A63DE8 /* products_list_1.json in Resources */ = {isa = PBXBuildFile; fileRef = 80AD2CA12782B4EB00A63DE8 /* products_list_1.json */; }; - 80AD2CA427858BAB00A63DE8 /* StatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AD2CA327858BAB00A63DE8 /* StatsTests.swift */; }; + 80AD2CA427858BAB00A63DE8 /* DashboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80AD2CA327858BAB00A63DE8 /* DashboardTests.swift */; }; 80B8D3452785A08900FE6E6B /* products_list_2.json in Resources */ = {isa = PBXBuildFile; fileRef = 80B8D3442785A08900FE6E6B /* products_list_2.json */; }; 80B8D3492785A0A900FE6E6B /* products_list_3.json in Resources */ = {isa = PBXBuildFile; fileRef = 80B8D3482785A0A900FE6E6B /* products_list_3.json */; }; 80B8D34C278E8A0C00FE6E6B /* MenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B8D34B278E8A0C00FE6E6B /* MenuScreen.swift */; }; @@ -1963,8 +1988,15 @@ B9001CE42B1E11A300EC87B2 /* CashPaymentTenderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9001CE32B1E11A300EC87B2 /* CashPaymentTenderViewModelTests.swift */; }; B90C65CD29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90C65CC29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift */; }; B90C65D129AD02CC004CAB9E /* CardPresentPaymentsOnboardingIPPUsersRefresherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90C65D029AD02CC004CAB9E /* CardPresentPaymentsOnboardingIPPUsersRefresherTests.swift */; }; + B90D21762D14269200ED60ED /* WooShippingCustomsForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D21752D14268800ED60ED /* WooShippingCustomsForm.swift */; }; + B90D21782D15B72900ED60ED /* WooShippingCustomsFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D21772D15B72700ED60ED /* WooShippingCustomsFormViewModel.swift */; }; + B90D217A2D1B06D000ED60ED /* WooShippingCustomsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D21792D1B06CE00ED60ED /* WooShippingCustomsItem.swift */; }; + B90D217C2D1B06F700ED60ED /* WooShippingCustomsItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D217B2D1B06F600ED60ED /* WooShippingCustomsItemViewModel.swift */; }; + B90D217E2D1ECA3100ED60ED /* WooShippingCustomsItemDescriptionInfoDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D217D2D1ECA1F00ED60ED /* WooShippingCustomsItemDescriptionInfoDialog.swift */; }; + B90D21802D1ED1F300ED60ED /* WooShippingCustomsItemOriginCountryInfoDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90D217F2D1ED1DE00ED60ED /* WooShippingCustomsItemOriginCountryInfoDialog.swift */; }; B90DACC02A30AEF000365897 /* BarcodeScannerItemFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90DACBF2A30AEF000365897 /* BarcodeScannerItemFinder.swift */; }; B90DACC22A31BBC800365897 /* BarcodeScannerProductFinderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90DACC12A31BBC800365897 /* BarcodeScannerProductFinderTests.swift */; }; + B90DD08E2D12FAA400EFC06A /* WooShippingCustomsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90DD08D2D12FA9900EFC06A /* WooShippingCustomsRow.swift */; }; B90DDF7C2A9E21DF009CFDA2 /* NewTaxRateSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B90DDF7B2A9E21DF009CFDA2 /* NewTaxRateSelectorView.swift */; }; B910686027F1F28F00AD0575 /* GhostableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B910685F27F1F28F00AD0575 /* GhostableViewController.swift */; }; B9151B3F2840EB330036180F /* WooFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B9151B3E2840EB330036180F /* WooFoundation.framework */; }; @@ -2150,7 +2182,6 @@ CCE73D2529EDAB5C0064E797 /* SubscriptionPeriod+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE73D2429EDAB5C0064E797 /* SubscriptionPeriod+UI.swift */; }; CCE785C829C1E8280003977F /* BundledProductsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE785C729C1E8280003977F /* BundledProductsListViewModelTests.swift */; }; CCE785CA29C1F9170003977F /* ProductBundleItemStockStatus+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE785C929C1F9170003977F /* ProductBundleItemStockStatus+UI.swift */; }; - CCEC256A27B581E800EF9FA3 /* ProductVariationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCEC256927B581E800EF9FA3 /* ProductVariationFormatter.swift */; }; CCF27B35280EF69700B755E1 /* orders_3337_add_product.json in Resources */ = {isa = PBXBuildFile; fileRef = CCF27B33280EF69600B755E1 /* orders_3337_add_product.json */; }; CCF27B3A280EF98F00B755E1 /* orders_3337.json in Resources */ = {isa = PBXBuildFile; fileRef = CCF27B39280EF98F00B755E1 /* orders_3337.json */; }; CCF87BBE279047BC00461C43 /* InfiniteScrollList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */; }; @@ -2268,6 +2299,7 @@ CE55F2D62B23941D005D53D7 /* CollapsibleProductCardPriceSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE55F2D52B23941D005D53D7 /* CollapsibleProductCardPriceSummary.swift */; }; CE55F2D82B23961B005D53D7 /* CollapsibleProductCardPriceSummaryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE55F2D72B23961B005D53D7 /* CollapsibleProductCardPriceSummaryViewModelTests.swift */; }; CE55F2DA2B28796E005D53D7 /* ProductDiscountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE55F2D92B28796E005D53D7 /* ProductDiscountViewModel.swift */; }; + CE56C01E2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56C01D2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift */; }; CE5757AF2B7E7F7400AEEB6D /* AnalyticsHubCustomizeViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5757AE2B7E7F7400AEEB6D /* AnalyticsHubCustomizeViewModelTests.swift */; }; CE583A0421076C0100D73C1C /* NewNoteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE583A0321076C0100D73C1C /* NewNoteViewController.swift */; }; CE583A072107849F00D73C1C /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE583A052107849F00D73C1C /* SwitchTableViewCell.swift */; }; @@ -2294,11 +2326,13 @@ CE6E110B2C91DA5D00563DD4 /* WooShippingItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E110A2C91DA5D00563DD4 /* WooShippingItemRow.swift */; }; CE6E110D2C91E5FF00563DD4 /* WooShippingItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E110C2C91E5FF00563DD4 /* WooShippingItems.swift */; }; CE6E110F2C91EF6800563DD4 /* View+RoundedBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E110E2C91EF6800563DD4 /* View+RoundedBorder.swift */; }; + CE7269C82D11A99800D565C1 /* WooShippingAddPackageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7269C72D11A99800D565C1 /* WooShippingAddPackageViewModelTests.swift */; }; CE7B4A582CA191FB00F764EB /* WooShippingItemRowViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7B4A572CA191F400F764EB /* WooShippingItemRowViewModelTests.swift */; }; CE7B4A5B2CA1BF9900F764EB /* WooShippingHazmat.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7B4A5A2CA1BF9900F764EB /* WooShippingHazmat.swift */; }; CE7CEC2D2C2EF0E50066FD53 /* GoogleAdsCampaignReportCardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEC2C2C2EF0E50066FD53 /* GoogleAdsCampaignReportCardViewModelTests.swift */; }; CE7F778B2C074D2500C89F4E /* EditableOrderShippingLineViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F778A2C074D2500C89F4E /* EditableOrderShippingLineViewModel.swift */; }; CE7F778D2C0770FF00C89F4E /* EditableOrderShippingLineViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7F778C2C0770FF00C89F4E /* EditableOrderShippingLineViewModelTests.swift */; }; + CE7FE6942D1324BD000F9475 /* WooShippingOriginAddressListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7FE6932D1324BD000F9475 /* WooShippingOriginAddressListView.swift */; }; CE85535D209B5BB700938BDC /* OrderDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85535C209B5BB700938BDC /* OrderDetailsViewModel.swift */; }; CE855365209BA6A700938BDC /* CustomerInfoTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = CE855361209BA6A700938BDC /* CustomerInfoTableViewCell.xib */; }; CE855366209BA6A700938BDC /* CustomerInfoTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE855362209BA6A700938BDC /* CustomerInfoTableViewCell.swift */; }; @@ -2327,6 +2361,8 @@ CEC3CC7C2C94A06500B93FBE /* WooShippingItemsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC3CC7B2C94A06500B93FBE /* WooShippingItemsDataSourceTests.swift */; }; CEC8188C2A3B7C8B00459843 /* AppStartupWaitingTimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC8188B2A3B7C8B00459843 /* AppStartupWaitingTimeTracker.swift */; }; CEC8188E2A3C75DD00459843 /* AppStartupWaitingTimeTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC8188D2A3C75DD00459843 /* AppStartupWaitingTimeTrackerTests.swift */; }; + CECAE70C2D22EFA4000AE10B /* WooShippingOriginAddressListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECAE70B2D22EF9B000AE10B /* WooShippingOriginAddressListViewModel.swift */; }; + CECAE7112D23168D000AE10B /* WooShippingOriginAddress+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECAE7102D23168D000AE10B /* WooShippingOriginAddress+Woo.swift */; }; CECC758623D21AC200486676 /* AggregateOrderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECC758523D21AC200486676 /* AggregateOrderItem.swift */; }; CECC758C23D2227000486676 /* ProductDetailsCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECC758B23D2227000486676 /* ProductDetailsCellViewModel.swift */; }; CECC759523D6057E00486676 /* OrderItem+Woo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECC759423D6057E00486676 /* OrderItem+Woo.swift */; }; @@ -2339,8 +2375,8 @@ CEE006062077D1280079161F /* SummaryTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = CEE006042077D1280079161F /* SummaryTableViewCell.xib */; }; CEE006082077D14C0079161F /* OrderDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE006072077D14C0079161F /* OrderDetailsViewController.swift */; }; CEE02AF82C1859B400B0B6AB /* MessageComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE02AF72C1859B400B0B6AB /* MessageComposeView.swift */; }; - CEE1138F2CFA2D8900F53E30 /* PackageOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE1138E2CFA2D8900F53E30 /* PackageOptionView.swift */; }; - CEE113952CFA2F7700F53E30 /* SelectedPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE113942CFA2F7700F53E30 /* SelectedPackageView.swift */; }; + CEE1138F2CFA2D8900F53E30 /* WooShippingPackageOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE1138E2CFA2D8900F53E30 /* WooShippingPackageOptionView.swift */; }; + CEE113952CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE113942CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift */; }; CEE125512CC66C8700D3183D /* WooShippingServiceCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE125502CC66C8100D3183D /* WooShippingServiceCardViewModel.swift */; }; CEE482D52B83A9A300FAC8C5 /* AnalyticsCard+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE482D42B83A9A300FAC8C5 /* AnalyticsCard+UI.swift */; }; CEEC9B6021E79CAA0055EEF0 /* FeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEC9B5F21E79CAA0055EEF0 /* FeatureFlagTests.swift */; }; @@ -2489,6 +2525,7 @@ DA013F512C65125100D9A391 /* PointOfSaleExitPosAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA013F502C65125100D9A391 /* PointOfSaleExitPosAlertView.swift */; }; DA0DBE2F2C4FC61D00DF14C0 /* POSFloatingControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0DBE2E2C4FC61D00DF14C0 /* POSFloatingControlView.swift */; }; DA1D68C22C36F0980097859A /* PointOfSaleAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1D68C12C36F0980097859A /* PointOfSaleAssets.swift */; }; + DA24152B2D116EAE0008F69A /* WooShippingAddPackageViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA24152A2D116EA90008F69A /* WooShippingAddPackageViewModelTests.swift */; }; DA25ADDD2C86145E00AE81FE /* MarkOrderAsReadUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA25ADDC2C86145E00AE81FE /* MarkOrderAsReadUseCase.swift */; }; DA25ADDF2C87403900AE81FE /* PushNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA25ADDE2C87403900AE81FE /* PushNotificationTests.swift */; }; DA3F99BA2C92F6D30034BDA5 /* MarkOrderAsReadUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA3F99B92C92F6D30034BDA5 /* MarkOrderAsReadUseCaseTests.swift */; }; @@ -2505,8 +2542,6 @@ DA81B4362C8F2BB8000F3466 /* MarkOrderAsReadUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA25ADDC2C86145E00AE81FE /* MarkOrderAsReadUseCase.swift */; }; DAAF53B82CF75701006D8880 /* WooShippingAddPackageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAAF53B72CF75701006D8880 /* WooShippingAddPackageViewModel.swift */; }; DAB4099F2CA5A329008EE1F2 /* WooShippingAddPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAB4099E2CA5A329008EE1F2 /* WooShippingAddPackageView.swift */; }; - DAD988C62C4A9CF9009DE9E3 /* CartItem+Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD988C52C4A9CF9009DE9E3 /* CartItem+Order.swift */; }; - DAD988C92C4A9D6C009DE9E3 /* CartItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAD988C82C4A9D6C009DE9E3 /* CartItemTests.swift */; }; DAF689E12CD23954008B8398 /* WooAddCustomPackageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAF689E02CD23941008B8398 /* WooAddCustomPackageView.swift */; }; DE001323279A793A00EB0350 /* CouponWooTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE001322279A793A00EB0350 /* CouponWooTests.swift */; }; DE0134152A2EED52000A6F54 /* ProductSharingMessageGenerationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0134142A2EED52000A6F54 /* ProductSharingMessageGenerationView.swift */; }; @@ -2760,6 +2795,10 @@ DEDB886B26E8531E00981595 /* ShippingLabelPackageAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEDB886A26E8531E00981595 /* ShippingLabelPackageAttributes.swift */; }; DEE183ED292BD900008818AB /* JetpackSetupViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE183EC292BD900008818AB /* JetpackSetupViewModelTests.swift */; }; DEE183F1292E0ED0008818AB /* JetpackSetupInterruptedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE183F0292E0ED0008818AB /* JetpackSetupInterruptedView.swift */; }; + DEE2152D2D113EF1004A11F3 /* EditStoreListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE2152C2D113EF1004A11F3 /* EditStoreListView.swift */; }; + DEE215302D113F89004A11F3 /* EditStoreListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE2152F2D113F84004A11F3 /* EditStoreListViewModel.swift */; }; + DEE215322D116FBB004A11F3 /* EditStoreListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE215312D116FBB004A11F3 /* EditStoreListViewModelTests.swift */; }; + DEE215342D1297CD004A11F3 /* UserDefaults+EditStoreList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE215332D1297CD004A11F3 /* UserDefaults+EditStoreList.swift */; }; DEE6437626D87C4100888A75 /* PrintCustomsFormsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE6437526D87C4100888A75 /* PrintCustomsFormsView.swift */; }; DEE6437826D8DAD900888A75 /* InProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEE6437726D8DAD900888A75 /* InProgressView.swift */; }; DEEDA239298A11FB0088256B /* SiteCredentialLoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEEDA238298A11FB0088256B /* SiteCredentialLoginUseCase.swift */; }; @@ -3148,10 +3187,14 @@ /* Begin PBXFileReference section */ 010C9A8E2C75C2BF00EBA228 /* Color+Inverted.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Inverted.swift"; sourceTree = ""; }; + 011D396E2D09FCCB00DB1445 /* CardPresentModalLocationRequired.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalLocationRequired.swift; sourceTree = ""; }; + 011D39702D0A324100DB1445 /* LocationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationServiceTests.swift; sourceTree = ""; }; 011D7A322CEC87770007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorEmailSent.swift; sourceTree = ""; }; 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorEmailSent.swift; sourceTree = ""; }; 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift; sourceTree = ""; }; 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift; sourceTree = ""; }; + 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 = ""; }; @@ -3174,14 +3217,20 @@ 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationViewModelTests.swift; sourceTree = ""; }; 01929C332CEF634E006C79ED /* CardPresentModalErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorWithoutEmail.swift; sourceTree = ""; }; 01929C352CEF6D6A006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalNonRetryableErrorWithoutEmail.swift; sourceTree = ""; }; + 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 = ""; }; 01ADC1352C9AB4810036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageViewModel.swift; sourceTree = ""; }; 01ADC1372C9AB6050036F7D2 /* PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentIntentCreationErrorMessageView.swift; sourceTree = ""; }; + 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalLocationPreAlert.swift; sourceTree = ""; }; + 01BB6C092D09E9630094D55B /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; 01BD77432C58CED400147191 /* PointOfSaleCardPresentPaymentProcessingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentProcessingMessageView.swift; sourceTree = ""; }; 01BD77452C58D0D000147191 /* PointOfSaleCardPresentPaymentSuccessMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentSuccessMessageView.swift; sourceTree = ""; }; 01BD77472C58D19C00147191 /* PointOfSaleCardPresentPaymentCancelledOnReaderMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCancelledOnReaderMessageView.swift; sourceTree = ""; }; 01BD77492C58D29700147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentDisconnectedMessageView.swift; sourceTree = ""; }; 01BD774B2C58D2BE00147191 /* PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentDisconnectedMessageViewModel.swift; sourceTree = ""; }; 01D0823F2C5B9EAB007FE81F /* POSBackgroundAppearanceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSBackgroundAppearanceKey.swift; sourceTree = ""; }; + 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockLocationService.swift; sourceTree = ""; }; 01F42C152CE34AB3003D0A5A /* CardPresentModalBuiltInSuccessEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalBuiltInSuccessEmailSent.swift; sourceTree = ""; }; 01F42C172CE34AD1003D0A5A /* CardPresentModalSuccessEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalSuccessEmailSent.swift; sourceTree = ""; }; 01F579942C7DE709008BCA28 /* PointOfSaleCardPresentPaymentCaptureErrorMessageViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCaptureErrorMessageViewModelTests.swift; sourceTree = ""; }; @@ -3307,6 +3356,7 @@ 0230B4D52C33454900F2F660 /* PointOfSaleCardPresentPaymentCaptureErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCaptureErrorMessageView.swift; sourceTree = ""; }; 0230B4D72C3345DF00F2F660 /* PointOfSaleCardPresentPaymentCaptureFailedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentCaptureFailedView.swift; sourceTree = ""; }; 02312796277D4F640060E180 /* StoreStatsPeriodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsPeriodViewModel.swift; sourceTree = ""; }; + 02335E482D13BA42000B6ECE /* AsyncPaginationTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncPaginationTracker.swift; sourceTree = ""; }; 023453F12579DA1A00A6BB20 /* ShippingLabelPrintingInstructionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPrintingInstructionsViewController.swift; sourceTree = ""; }; 02346809282CEA5F00CFC503 /* LegacyReceiptViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyReceiptViewModelTests.swift; sourceTree = ""; }; 0235354D2999D17A00BF77D3 /* DomainSettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSettingsViewModelTests.swift; sourceTree = ""; }; @@ -3441,7 +3491,7 @@ 026826982BF59DA80036F959 /* Color+WooCommercePOS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Color+WooCommercePOS.swift"; sourceTree = ""; }; 026826A22BF59DF60036F959 /* ItemRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemRowView.swift; sourceTree = ""; }; 026826A32BF59DF60036F959 /* CartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartView.swift; sourceTree = ""; }; - 026826A42BF59DF60036F959 /* ItemCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCardView.swift; sourceTree = ""; }; + 026826A42BF59DF60036F959 /* SimpleProductCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleProductCardView.swift; sourceTree = ""; }; 026826A52BF59DF60036F959 /* PointOfSaleDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleDashboardView.swift; sourceTree = ""; }; 026826A72BF59DF70036F959 /* PointOfSaleEntryPointView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleEntryPointView.swift; sourceTree = ""; }; 026826B32BF59E320036F959 /* CardReaderConnectionStatusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardReaderConnectionStatusView.swift; sourceTree = ""; }; @@ -3490,6 +3540,9 @@ 0279F0E3252DC9670098D7DE /* ProductVariationLoadUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationLoadUseCase.swift; sourceTree = ""; }; 027A2E132513124E00DA6ACB /* Keychain+Entries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Keychain+Entries.swift"; sourceTree = ""; }; 027A2E152513356100DA6ACB /* AppleIDCredentialChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleIDCredentialChecker.swift; sourceTree = ""; }; + 027ADB6D2D1BF5E3009608DB /* ParentProductCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParentProductCardView.swift; sourceTree = ""; }; + 027ADB722D21812D009608DB /* POSItemImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemImageView.swift; sourceTree = ""; }; + 027ADB742D218A8D009608DB /* POSItemCardBorderStylesModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSItemCardBorderStylesModifier.swift; sourceTree = ""; }; 027B8BB723FE0CB30040944E /* DefaultProductUIImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultProductUIImageLoader.swift; sourceTree = ""; }; 027B8BBC23FE0DE10040944E /* ProductImageActionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageActionHandlerTests.swift; sourceTree = ""; }; 027B8BBE23FE0F850040944E /* MockMediaStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaStoresManager.swift; sourceTree = ""; }; @@ -3543,6 +3596,9 @@ 029106C12BE34A8600C2248B /* CollapsibleCustomerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCard.swift; sourceTree = ""; }; 029106C32BE34AA900C2248B /* CollapsibleCustomerCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleCustomerCardViewModel.swift; sourceTree = ""; }; 02913E9423A774C500707A0C /* UnitInputFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitInputFormatter.swift; sourceTree = ""; }; + 029149772D26658A00F7B3B3 /* VariationCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariationCardView.swift; sourceTree = ""; }; + 0291497A2D2682FF00F7B3B3 /* ItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemList.swift; sourceTree = ""; }; + 0291497C2D26CB2500F7B3B3 /* ChildItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildItemList.swift; sourceTree = ""; }; 0294F8AA25E8A12C005B537A /* WooTabNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooTabNavigationController.swift; sourceTree = ""; }; 02952B5027808B08008E9BA3 /* StoreStatsPeriodViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreStatsPeriodViewModelTests.swift; sourceTree = ""; }; 0295355A245ADF8100BDC42B /* FilterType+Products.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FilterType+Products.swift"; sourceTree = ""; }; @@ -3920,6 +3976,7 @@ 203163BA2C1C5F72001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdatePostalCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentConnectingFailedUpdatePostalCodeView.swift; sourceTree = ""; }; 203163BC2C1C9602001C96DA /* PointOfSaleCardPresentPaymentAlertType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentAlertType.swift; sourceTree = ""; }; 203A5C302AC5ADD700BF29A1 /* WooPaymentsPayoutsOverviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsPayoutsOverviewView.swift; sourceTree = ""; }; + 203AB2A72D01B97D001D989C /* OrderCustomAmountsSectionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCustomAmountsSectionViewModelTests.swift; sourceTree = ""; }; 2044158C2CE4DB480070BF54 /* PointOfSaleOrderStage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderStage.swift; sourceTree = ""; }; 2044158E2CE6181E0070BF54 /* PointOfSaleOrderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderState.swift; sourceTree = ""; }; 204415902CE622BA0070BF54 /* PointOfSaleOrderTotals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderTotals.swift; sourceTree = ""; }; @@ -4001,6 +4058,7 @@ 20D3D4322C65E59B004CE6E3 /* OrdersRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersRoute.swift; sourceTree = ""; }; 20D3D4342C65E640004CE6E3 /* OrdersDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersDestination.swift; sourceTree = ""; }; 20D3D4362C65EF72004CE6E3 /* OrdersRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrdersRouteTests.swift; sourceTree = ""; }; + 20D4AE002D133B43004555B2 /* ItemsStackState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsStackState.swift; sourceTree = ""; }; 20D5CB502AFCF856009A39C3 /* PaymentsRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsRow.swift; sourceTree = ""; }; 20D5CB522AFCF8E7009A39C3 /* PaymentsToggleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsToggleRow.swift; sourceTree = ""; }; 20D920E92CEF86520023B089 /* PointOfSaleErrorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleErrorState.swift; sourceTree = ""; }; @@ -4009,6 +4067,8 @@ 20DB185C2CF5E7560018D3E1 /* PointOfSaleOrderControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderControllerTests.swift; sourceTree = ""; }; 20E014E12CF63671008C823B /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 20E188832AD059A50053E945 /* AboutTapToPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutTapToPayView.swift; sourceTree = ""; }; + 20F7B12C2D12C7B900C08193 /* ItemsContainerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsContainerState.swift; sourceTree = ""; }; + 20F7B12E2D12CBE700C08193 /* ItemsViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewState.swift; sourceTree = ""; }; 20FA73872CDCC3A900554BE3 /* OrderDetailsSyncStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsSyncStateController.swift; sourceTree = ""; }; 20FCBCDC2CE223340082DCA3 /* PointOfSaleAggregateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleAggregateModel.swift; sourceTree = ""; }; 20FCBCDE2CE241810082DCA3 /* PointOfSaleAggregateModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleAggregateModelTests.swift; sourceTree = ""; }; @@ -4583,6 +4643,7 @@ 57F2C6CC246DECC10074063B /* SummaryTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryTableViewCellViewModelTests.swift; sourceTree = ""; }; 57F42E3F253768D600EA87F7 /* TitleAndEditableValueTableViewCellViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleAndEditableValueTableViewCellViewModelTests.swift; sourceTree = ""; }; 581D5051274AA2480089B6AD /* View+AutofocusTextModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AutofocusTextModifier.swift"; sourceTree = ""; }; + 6801E4162D0FFF0100F9DF46 /* MockReceiptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockReceiptService.swift; sourceTree = ""; }; 680BA5992A4C377900F5559D /* UpgradeViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradeViewState.swift; sourceTree = ""; }; 680E36B42BD8B9B900E8BCEA /* OrderSubscriptionTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = OrderSubscriptionTableViewCell.xib; sourceTree = ""; }; 680E36B62BD8C49F00E8BCEA /* OrderSubscriptionTableViewCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderSubscriptionTableViewCellViewModel.swift; sourceTree = ""; }; @@ -4595,7 +4656,7 @@ 6837631B2C2E847D00AD51D0 /* CartViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartViewHelper.swift; sourceTree = ""; }; 683988A62C7D82E60084B85A /* POSHeaderLayoutConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSHeaderLayoutConstants.swift; sourceTree = ""; }; 683AA9D52A303CB70099F7BA /* UpgradesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModelTests.swift; sourceTree = ""; }; - 683AC4AB2CEF019700FF0A5E /* POSSendReceiptModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSendReceiptModalView.swift; sourceTree = ""; }; + 683AC4AB2CEF019700FF0A5E /* POSSendReceiptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSendReceiptView.swift; sourceTree = ""; }; 683DF5FE2C6AF46500A5CDC6 /* POSHeaderTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSHeaderTitleView.swift; sourceTree = ""; }; 684AB8392870677F003DFDD1 /* CardReaderManualsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsView.swift; sourceTree = ""; }; 684AB83B2873DF04003DFDD1 /* CardReaderManualsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModel.swift; sourceTree = ""; }; @@ -4620,10 +4681,12 @@ 6885E2CB2C32B14B004C8D70 /* TotalsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalsViewHelper.swift; sourceTree = ""; }; 6888A2C72A668D650026F5C0 /* FullFeatureListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullFeatureListView.swift; sourceTree = ""; }; 6888A2C92A66C42C0026F5C0 /* FullFeatureListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullFeatureListViewModel.swift; sourceTree = ""; }; + 68A345632D029E09002EE324 /* PaymentButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentButtons.swift; sourceTree = ""; }; 68A38DF42B293B030090C263 /* MockProductListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProductListViewModel.swift; sourceTree = ""; }; 68A5221A2BA1804900A6A584 /* PluginDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginDetailsViewModelTests.swift; sourceTree = ""; }; 68A905002ACCFC13004C71D3 /* CollapsibleProductCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductCard.swift; sourceTree = ""; }; 68AC9D282ACE598B0042F784 /* ProductImageThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageThumbnail.swift; sourceTree = ""; }; + 68AF3C3A2D01481A006F1ED2 /* POSReceiptEligibilityBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptEligibilityBanner.swift; sourceTree = ""; }; 68B6F22A2ADE7ED500D171FC /* TooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipView.swift; sourceTree = ""; }; 68C31B702A8617C500AE5C5A /* NewNoteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNoteViewModel.swift; sourceTree = ""; }; 68C53CBD2C1FE59B00C6D80B /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = ""; }; @@ -4635,6 +4698,7 @@ 68D8FBD02BFEF9C700477C42 /* TotalsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalsView.swift; sourceTree = ""; }; 68DF5A8C2CB38EEA000154C9 /* EditableOrderCouponLineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableOrderCouponLineViewModel.swift; sourceTree = ""; }; 68DF5A8E2CB38F20000154C9 /* OrderCouponSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCouponSectionView.swift; sourceTree = ""; }; + 68E141DA2D13107200A70D5B /* PointOfSaleCollectCashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCollectCashView.swift; sourceTree = ""; }; 68E4E8B42C0EF39D00CFA0C3 /* PreviewHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewHelpers.swift; sourceTree = ""; }; 68E6749E2A4DA01C0034BA1E /* WooWPComPlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooWPComPlan.swift; sourceTree = ""; }; 68E674A02A4DA0B30034BA1E /* InAppPurchasesError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppPurchasesError.swift; sourceTree = ""; }; @@ -4746,7 +4810,7 @@ 80A6430D2A026D0800F65C0C /* complete_cash_simple_payment.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = complete_cash_simple_payment.json; sourceTree = ""; }; 80A643112A0270F100F65C0C /* orders_complete_simple_payment.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = orders_complete_simple_payment.json; sourceTree = ""; }; 80AD2CA12782B4EB00A63DE8 /* products_list_1.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = products_list_1.json; sourceTree = ""; }; - 80AD2CA327858BAB00A63DE8 /* StatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTests.swift; sourceTree = ""; }; + 80AD2CA327858BAB00A63DE8 /* DashboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardTests.swift; sourceTree = ""; }; 80B8D3442785A08900FE6E6B /* products_list_2.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = products_list_2.json; sourceTree = ""; }; 80B8D3482785A0A900FE6E6B /* products_list_3.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = products_list_3.json; sourceTree = ""; }; 80B8D34B278E8A0C00FE6E6B /* MenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuScreen.swift; sourceTree = ""; }; @@ -5060,8 +5124,15 @@ B9001CE32B1E11A300EC87B2 /* CashPaymentTenderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashPaymentTenderViewModelTests.swift; sourceTree = ""; }; B90C65CC29ACE2D6004CAB9E /* CardPresentPaymentOnboardingStateCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPaymentOnboardingStateCache.swift; sourceTree = ""; }; B90C65D029AD02CC004CAB9E /* CardPresentPaymentsOnboardingIPPUsersRefresherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentPaymentsOnboardingIPPUsersRefresherTests.swift; sourceTree = ""; }; + B90D21752D14268800ED60ED /* WooShippingCustomsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsForm.swift; sourceTree = ""; }; + B90D21772D15B72700ED60ED /* WooShippingCustomsFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsFormViewModel.swift; sourceTree = ""; }; + B90D21792D1B06CE00ED60ED /* WooShippingCustomsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsItem.swift; sourceTree = ""; }; + B90D217B2D1B06F600ED60ED /* WooShippingCustomsItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsItemViewModel.swift; sourceTree = ""; }; + B90D217D2D1ECA1F00ED60ED /* WooShippingCustomsItemDescriptionInfoDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsItemDescriptionInfoDialog.swift; sourceTree = ""; }; + B90D217F2D1ED1DE00ED60ED /* WooShippingCustomsItemOriginCountryInfoDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsItemOriginCountryInfoDialog.swift; sourceTree = ""; }; B90DACBF2A30AEF000365897 /* BarcodeScannerItemFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerItemFinder.swift; sourceTree = ""; }; B90DACC12A31BBC800365897 /* BarcodeScannerProductFinderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScannerProductFinderTests.swift; sourceTree = ""; }; + B90DD08D2D12FA9900EFC06A /* WooShippingCustomsRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingCustomsRow.swift; sourceTree = ""; }; B90DDF7B2A9E21DF009CFDA2 /* NewTaxRateSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTaxRateSelectorView.swift; sourceTree = ""; }; B910685F27F1F28F00AD0575 /* GhostableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostableViewController.swift; sourceTree = ""; }; B9151B3E2840EB330036180F /* WooFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WooFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -5250,7 +5321,6 @@ CCE73D2429EDAB5C0064E797 /* SubscriptionPeriod+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SubscriptionPeriod+UI.swift"; sourceTree = ""; }; CCE785C729C1E8280003977F /* BundledProductsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledProductsListViewModelTests.swift; sourceTree = ""; }; CCE785C929C1F9170003977F /* ProductBundleItemStockStatus+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductBundleItemStockStatus+UI.swift"; sourceTree = ""; }; - CCEC256927B581E800EF9FA3 /* ProductVariationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationFormatter.swift; sourceTree = ""; }; CCF27B33280EF69600B755E1 /* orders_3337_add_product.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = orders_3337_add_product.json; sourceTree = ""; }; CCF27B39280EF98F00B755E1 /* orders_3337.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = orders_3337.json; sourceTree = ""; }; CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteScrollList.swift; sourceTree = ""; }; @@ -5370,6 +5440,7 @@ CE55F2D52B23941D005D53D7 /* CollapsibleProductCardPriceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductCardPriceSummary.swift; sourceTree = ""; }; CE55F2D72B23961B005D53D7 /* CollapsibleProductCardPriceSummaryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductCardPriceSummaryViewModelTests.swift; sourceTree = ""; }; CE55F2D92B28796E005D53D7 /* ProductDiscountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDiscountViewModel.swift; sourceTree = ""; }; + CE56C01D2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingOriginAddressListViewModelTests.swift; sourceTree = ""; }; CE5757AE2B7E7F7400AEEB6D /* AnalyticsHubCustomizeViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHubCustomizeViewModelTests.swift; sourceTree = ""; }; CE583A0321076C0100D73C1C /* NewNoteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNoteViewController.swift; sourceTree = ""; }; CE583A052107849F00D73C1C /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; @@ -5396,11 +5467,13 @@ CE6E110A2C91DA5D00563DD4 /* WooShippingItemRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingItemRow.swift; sourceTree = ""; }; CE6E110C2C91E5FF00563DD4 /* WooShippingItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingItems.swift; sourceTree = ""; }; CE6E110E2C91EF6800563DD4 /* View+RoundedBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+RoundedBorder.swift"; sourceTree = ""; }; + CE7269C72D11A99800D565C1 /* WooShippingAddPackageViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAddPackageViewModelTests.swift; sourceTree = ""; }; CE7B4A572CA191F400F764EB /* WooShippingItemRowViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingItemRowViewModelTests.swift; sourceTree = ""; }; CE7B4A5A2CA1BF9900F764EB /* WooShippingHazmat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingHazmat.swift; sourceTree = ""; }; CE7CEC2C2C2EF0E50066FD53 /* GoogleAdsCampaignReportCardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAdsCampaignReportCardViewModelTests.swift; sourceTree = ""; }; CE7F778A2C074D2500C89F4E /* EditableOrderShippingLineViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableOrderShippingLineViewModel.swift; sourceTree = ""; }; CE7F778C2C0770FF00C89F4E /* EditableOrderShippingLineViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableOrderShippingLineViewModelTests.swift; sourceTree = ""; }; + CE7FE6932D1324BD000F9475 /* WooShippingOriginAddressListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingOriginAddressListView.swift; sourceTree = ""; }; CE85535C209B5BB700938BDC /* OrderDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsViewModel.swift; sourceTree = ""; }; CE855361209BA6A700938BDC /* CustomerInfoTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = CustomerInfoTableViewCell.xib; sourceTree = ""; }; CE855362209BA6A700938BDC /* CustomerInfoTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomerInfoTableViewCell.swift; sourceTree = ""; }; @@ -5430,6 +5503,8 @@ CEC8188B2A3B7C8B00459843 /* AppStartupWaitingTimeTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStartupWaitingTimeTracker.swift; sourceTree = ""; }; CEC8188D2A3C75DD00459843 /* AppStartupWaitingTimeTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStartupWaitingTimeTrackerTests.swift; sourceTree = ""; }; CECA64B020D9990E005A44C4 /* WooCommerce-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WooCommerce-Bridging-Header.h"; sourceTree = ""; }; + CECAE70B2D22EF9B000AE10B /* WooShippingOriginAddressListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingOriginAddressListViewModel.swift; sourceTree = ""; }; + CECAE7102D23168D000AE10B /* WooShippingOriginAddress+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingOriginAddress+Woo.swift"; sourceTree = ""; }; CECC758523D21AC200486676 /* AggregateOrderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AggregateOrderItem.swift; sourceTree = ""; }; CECC758B23D2227000486676 /* ProductDetailsCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailsCellViewModel.swift; sourceTree = ""; }; CECC759423D6057E00486676 /* OrderItem+Woo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderItem+Woo.swift"; sourceTree = ""; }; @@ -5442,8 +5517,8 @@ CEE006042077D1280079161F /* SummaryTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SummaryTableViewCell.xib; sourceTree = ""; }; CEE006072077D14C0079161F /* OrderDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsViewController.swift; sourceTree = ""; }; CEE02AF72C1859B400B0B6AB /* MessageComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposeView.swift; sourceTree = ""; }; - CEE1138E2CFA2D8900F53E30 /* PackageOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageOptionView.swift; sourceTree = ""; }; - CEE113942CFA2F7700F53E30 /* SelectedPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectedPackageView.swift; sourceTree = ""; }; + CEE1138E2CFA2D8900F53E30 /* WooShippingPackageOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingPackageOptionView.swift; sourceTree = ""; }; + CEE113942CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingSelectedPackageView.swift; sourceTree = ""; }; CEE125502CC66C8100D3183D /* WooShippingServiceCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingServiceCardViewModel.swift; sourceTree = ""; }; CEE482D42B83A9A300FAC8C5 /* AnalyticsCard+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnalyticsCard+UI.swift"; sourceTree = ""; }; CEEC9B5F21E79CAA0055EEF0 /* FeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagTests.swift; sourceTree = ""; }; @@ -5599,6 +5674,7 @@ DA0DBE2E2C4FC61D00DF14C0 /* POSFloatingControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSFloatingControlView.swift; sourceTree = ""; }; DA1AE99A10B90748C7676E95 /* Pods-StoreWidgetsExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StoreWidgetsExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-StoreWidgetsExtension/Pods-StoreWidgetsExtension.debug.xcconfig"; sourceTree = ""; }; DA1D68C12C36F0980097859A /* PointOfSaleAssets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PointOfSaleAssets.swift; sourceTree = ""; }; + DA24152A2D116EA90008F69A /* WooShippingAddPackageViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAddPackageViewModelTests.swift; sourceTree = ""; }; DA25ADDC2C86145E00AE81FE /* MarkOrderAsReadUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkOrderAsReadUseCase.swift; sourceTree = ""; }; DA25ADDE2C87403900AE81FE /* PushNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationTests.swift; sourceTree = ""; }; DA3F99B92C92F6D30034BDA5 /* MarkOrderAsReadUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkOrderAsReadUseCaseTests.swift; sourceTree = ""; }; @@ -5609,8 +5685,6 @@ DA4104392C247B6900E8456A /* PointOfSalePreviewOrderController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSalePreviewOrderController.swift; sourceTree = ""; }; DAAF53B72CF75701006D8880 /* WooShippingAddPackageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAddPackageViewModel.swift; sourceTree = ""; }; DAB4099E2CA5A329008EE1F2 /* WooShippingAddPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooShippingAddPackageView.swift; sourceTree = ""; }; - DAD988C52C4A9CF9009DE9E3 /* CartItem+Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CartItem+Order.swift"; sourceTree = ""; }; - DAD988C82C4A9D6C009DE9E3 /* CartItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartItemTests.swift; sourceTree = ""; }; DAF689E02CD23941008B8398 /* WooAddCustomPackageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooAddCustomPackageView.swift; sourceTree = ""; }; DE001322279A793A00EB0350 /* CouponWooTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponWooTests.swift; sourceTree = ""; }; DE0134142A2EED52000A6F54 /* ProductSharingMessageGenerationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductSharingMessageGenerationView.swift; sourceTree = ""; }; @@ -5862,6 +5936,10 @@ DEDB886A26E8531E00981595 /* ShippingLabelPackageAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPackageAttributes.swift; sourceTree = ""; }; DEE183EC292BD900008818AB /* JetpackSetupViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackSetupViewModelTests.swift; sourceTree = ""; }; DEE183F0292E0ED0008818AB /* JetpackSetupInterruptedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackSetupInterruptedView.swift; sourceTree = ""; }; + DEE2152C2D113EF1004A11F3 /* EditStoreListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStoreListView.swift; sourceTree = ""; }; + DEE2152F2D113F84004A11F3 /* EditStoreListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStoreListViewModel.swift; sourceTree = ""; }; + DEE215312D116FBB004A11F3 /* EditStoreListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStoreListViewModelTests.swift; sourceTree = ""; }; + DEE215332D1297CD004A11F3 /* UserDefaults+EditStoreList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+EditStoreList.swift"; sourceTree = ""; }; DEE6437526D87C4100888A75 /* PrintCustomsFormsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintCustomsFormsView.swift; sourceTree = ""; }; DEE6437726D8DAD900888A75 /* InProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InProgressView.swift; sourceTree = ""; }; DEEDA238298A11FB0088256B /* SiteCredentialLoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCredentialLoginUseCase.swift; sourceTree = ""; }; @@ -6260,8 +6338,10 @@ 683DF5FE2C6AF46500A5CDC6 /* POSHeaderTitleView.swift */, 016C6B962C74AB17000D86FD /* POSConnectivityView.swift */, 68D3E98C2C7C371B005B6278 /* POSBottomShadowViewModifier.swift */, - 683AC4AB2CEF019700FF0A5E /* POSSendReceiptModalView.swift */, + 683AC4AB2CEF019700FF0A5E /* POSSendReceiptView.swift */, 20D2CCA22C7E175700051705 /* WavesProgressViewStyle.swift */, + 027ADB722D21812D009608DB /* POSItemImageView.swift */, + 027ADB742D218A8D009608DB /* POSItemCardBorderStylesModifier.swift */, ); path = "Reusable Views"; sourceTree = ""; @@ -6278,14 +6358,25 @@ 019130152CF49A52008C0C88 /* Tap to Pay Education */ = { isa = PBXGroup; children = ( + 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */, 0191301E2CF4E809008C0C88 /* TapToPayEducationStepView.swift */, 019130182CF49A77008C0C88 /* TapToPayEducationView.swift */, 0191301C2CF4E7B6008C0C88 /* TapToPayEducationViewModel.swift */, 0191301A2CF4E77F008C0C88 /* TapToPayEducationStepViewModel.swift */, + 019630B52D02018400219D80 /* TapToPayAwarenessMomentDeterminer.swift */, + 019630B32D01DB4000219D80 /* TapToPayAwarenessMomentView.swift */, ); path = "Tap to Pay Education"; sourceTree = ""; }; + 01BB6C082D09E9200094D55B /* Location */ = { + isa = PBXGroup; + children = ( + 01BB6C092D09E9630094D55B /* LocationService.swift */, + ); + path = Location; + sourceTree = ""; + }; 0202B6932387ACE000F3EBE0 /* TabBar */ = { isa = PBXGroup; children = ( @@ -6910,22 +7001,28 @@ 026826A12BF59DED0036F959 /* Presentation */ = { isa = PBXGroup; children = ( + 029149792D2682DF00F7B3B3 /* Item Selector */, 01620C4C2C5394A400D3EA2F /* Reusable Views */, 02E71DB42C118C470036C2FD /* Card Present Payments */, 026826B02BF59E170036F959 /* CardReaderConnection */, 014BD4B62C64E26B0011A66E /* Order Messages */, 02B1914A2CCF278100CF38C9 /* Payments Onboarding */, 026826A72BF59DF70036F959 /* PointOfSaleEntryPointView.swift */, + 68A345632D029E09002EE324 /* PaymentButtons.swift */, 026826A52BF59DF60036F959 /* PointOfSaleDashboardView.swift */, + 68E141DA2D13107200A70D5B /* PointOfSaleCollectCashView.swift */, DA013F502C65125100D9A391 /* PointOfSaleExitPosAlertView.swift */, DA0DBE2E2C4FC61D00DF14C0 /* POSFloatingControlView.swift */, 20D3D42A2C64D7CC004CE6E3 /* SimpleProductsOnlyInformation.swift */, 68C53CBD2C1FE59B00C6D80B /* ItemListView.swift */, 026826A32BF59DF60036F959 /* CartView.swift */, 026826A22BF59DF60036F959 /* ItemRowView.swift */, - 026826A42BF59DF60036F959 /* ItemCardView.swift */, + 026826A42BF59DF60036F959 /* SimpleProductCardView.swift */, 68D8FBD02BFEF9C700477C42 /* TotalsView.swift */, + 68AF3C3A2D01481A006F1ED2 /* POSReceiptEligibilityBanner.swift */, DA1D68C12C36F0980097859A /* PointOfSaleAssets.swift */, + 027ADB6D2D1BF5E3009608DB /* ParentProductCardView.swift */, + 029149772D26658A00F7B3B3 /* VariationCardView.swift */, ); path = Presentation; sourceTree = ""; @@ -7191,6 +7288,15 @@ path = UnitInputFormatter; sourceTree = ""; }; + 029149792D2682DF00F7B3B3 /* Item Selector */ = { + isa = PBXGroup; + children = ( + 0291497A2D2682FF00F7B3B3 /* ItemList.swift */, + 0291497C2D26CB2500F7B3B3 /* ChildItemList.swift */, + ); + path = "Item Selector"; + sourceTree = ""; + }; 029327662BF59D2D00D703E7 /* POS */ = { isa = PBXGroup; children = ( @@ -7211,6 +7317,7 @@ children = ( 02ECD1DE24FF48D000735BE5 /* PaginationTracker.swift */, 029700EB24FE38C900D242F8 /* ScrollWatcher.swift */, + 02335E482D13BA42000B6ECE /* AsyncPaginationTracker.swift */, ); path = InfiniteScroll; sourceTree = ""; @@ -7405,6 +7512,7 @@ isa = PBXGroup; children = ( 02CD3BFD2C35D04C00E575C4 /* MockCardPresentPaymentService.swift */, + 6801E4162D0FFF0100F9DF46 /* MockReceiptService.swift */, 207E71CA2C60F765008540FC /* MockPOSOrderService.swift */, 20DB185A2CF5D9220018D3E1 /* MockPointOfSaleOrderController.swift */, 20FCBCE02CE24CE70082DCA3 /* MockPOSItemProvider.swift */, @@ -7695,6 +7803,7 @@ 20B0D65D2AD45BDE0059735A /* AboutTapToPayContactlessLimitViewModelTests.swift */, 03B9E52A2A1505A7005C77F5 /* TapToPayReconnectionControllerTests.swift */, 019130202CF5B0FF008C0C88 /* TapToPayEducationViewModelTests.swift */, + 019630B72D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift */, ); path = "In-Person Payments"; sourceTree = ""; @@ -9373,6 +9482,7 @@ DE61978C289A5326005E4362 /* WooSetupWebViewModelTests.swift */, DE61978E289A5674005E4362 /* NoWooErrorViewModelTests.swift */, DE61979428A25842005E4362 /* StorePickerViewModelTests.swift */, + DEE215312D116FBB004A11F3 /* EditStoreListViewModelTests.swift */, DE3404E928B4C1D000CF0D97 /* NonAtomicSiteViewModelTests.swift */, DE50295228BF4A8A00551736 /* JetpackConnectionWebViewModelTests.swift */, 020D0BFC2914E92800BB3DCE /* StorePickerCoordinatorTests.swift */, @@ -9533,6 +9643,9 @@ children = ( 20FCBCDC2CE223340082DCA3 /* PointOfSaleAggregateModel.swift */, 20C6E7502CDE4AEA00CD124C /* ItemListState.swift */, + 20F7B12C2D12C7B900C08193 /* ItemsContainerState.swift */, + 20F7B12E2D12CBE700C08193 /* ItemsViewState.swift */, + 20D4AE002D133B43004555B2 /* ItemsStackState.swift */, 68F151E02C0DA7910082AEC8 /* CartItem.swift */, 20D920E92CEF86520023B089 /* PointOfSaleErrorState.swift */, 2044158C2CE4DB480070BF54 /* PointOfSaleOrderStage.swift */, @@ -9577,6 +9690,7 @@ 746791642108D853007CF1DC /* Mocks */ = { isa = PBXGroup; children = ( + 01F067EC2D0C5D56001C5805 /* MockLocationService.swift */, 0182C8BD2CE3B10E00474355 /* MockReceiptEligibilityUseCase.swift */, EEA3C2202CA5440A000E82EC /* MockFavoriteProductsUseCase.swift */, D85136CC231E15B700DD0539 /* MockReviews.swift */, @@ -10148,6 +10262,7 @@ EEADF625281A65A9001B40F1 /* DefaultShippingValueLocalizerTests.swift */, DE9A02A22A44441200193ABF /* RequirementsCheckerTests.swift */, 26DDA4AA2C49627F005FBEBF /* DashboardTimestampStoreTests.swift */, + 011D39702D0A324100DB1445 /* LocationServiceTests.swift */, ); path = Tools; sourceTree = ""; @@ -10228,6 +10343,7 @@ B55D4C2220B716CE00D7A50F /* Tools */ = { isa = PBXGroup; children = ( + 01BB6C082D09E9200094D55B /* Location */, 26BCA03E2C35E965000BE96C /* BackgroundTasks */, EEADF61C281A3DB7001B40F1 /* ShippingValueLocalizer */, DE792E1926EF37D80071200C /* Connectivity */, @@ -10659,6 +10775,7 @@ B5D1AFC420BC7B3000DB0E8C /* Epilogue */ = { isa = PBXGroup; children = ( + DEE2152E2D113F61004A11F3 /* EditStoreList */, B57C743C20F5493300EEFC87 /* AccountHeaderView.swift */, B57C744220F54F1C00EEFC87 /* AccountHeaderView.xib */, B57C744920F5649300EEFC87 /* EmptyStoresTableViewCell.swift */, @@ -10798,6 +10915,20 @@ path = Cash; sourceTree = ""; }; + B90DD08C2D12FA6600EFC06A /* WooShipping Customs */ = { + isa = PBXGroup; + children = ( + B90D217F2D1ED1DE00ED60ED /* WooShippingCustomsItemOriginCountryInfoDialog.swift */, + B90D217D2D1ECA1F00ED60ED /* WooShippingCustomsItemDescriptionInfoDialog.swift */, + B90D217B2D1B06F600ED60ED /* WooShippingCustomsItemViewModel.swift */, + B90D21792D1B06CE00ED60ED /* WooShippingCustomsItem.swift */, + B90D21772D15B72700ED60ED /* WooShippingCustomsFormViewModel.swift */, + B90D21752D14268800ED60ED /* WooShippingCustomsForm.swift */, + B90DD08D2D12FA9900EFC06A /* WooShippingCustomsRow.swift */, + ); + path = "WooShipping Customs"; + sourceTree = ""; + }; B912603F2940B2C400CACD4B /* JustInTimeMessages */ = { isa = PBXGroup; children = ( @@ -10999,6 +11130,7 @@ B9F1489B2AD59F31008FC795 /* CustomAmounts */ = { isa = PBXGroup; children = ( + 203AB2A72D01B97D001D989C /* OrderCustomAmountsSectionViewModelTests.swift */, B9F1489C2AD59F42008FC795 /* FormattableAmountTextFieldViewModelTests.swift */, B92932E02AD5A616005B3153 /* AddCustomAmountViewModelTests.swift */, ); @@ -11257,7 +11389,7 @@ 800A5B57275483D6009DE2CD /* OrdersTests.swift */, 800A5BCA2759CE4B009DE2CD /* ProductsTests.swift */, 80C3626E277453E7005CEAD3 /* ReviewsTests.swift */, - 80AD2CA327858BAB00A63DE8 /* StatsTests.swift */, + 80AD2CA327858BAB00A63DE8 /* DashboardTests.swift */, 80CA638829C05E7B002E6BE6 /* PaymentsTests.swift */, 80C5D4E029E90DAC00352EC7 /* UniversalLinksTests.swift */, 80C5D4E229ECFFE400352EC7 /* SupportsTests.swift */, @@ -11938,6 +12070,16 @@ path = "WooShipping Hazmat Section"; sourceTree = ""; }; + CE7FE6922D13249D000F9475 /* WooShippingAddresses */ = { + isa = PBXGroup; + children = ( + CECAE7102D23168D000AE10B /* WooShippingOriginAddress+Woo.swift */, + CE7FE6932D1324BD000F9475 /* WooShippingOriginAddressListView.swift */, + CECAE70B2D22EF9B000AE10B /* WooShippingOriginAddressListViewModel.swift */, + ); + path = WooShippingAddresses; + sourceTree = ""; + }; CE85535B209B5B6A00938BDC /* ViewModels */ = { isa = PBXGroup; children = ( @@ -11948,7 +12090,6 @@ D817585C22BB5E6900289CFE /* Order Details */, 0371C36C2876E91500277E2C /* Feature Announcement Cards */, D843D5D82248EE90001BFA55 /* ManualTrackingViewModel.swift */, - CCEC256927B581E800EF9FA3 /* ProductVariationFormatter.swift */, CECC758B23D2227000486676 /* ProductDetailsCellViewModel.swift */, D843D5D622485B19001BFA55 /* ShippingProvidersViewModel.swift */, D8736B5922F07D7100A14A29 /* MainTabViewModel.swift */, @@ -12033,9 +12174,11 @@ CEAB739A2C81E3A000A7EB39 /* WooShipping Create Shipping Labels */ = { isa = PBXGroup; children = ( + B90DD08C2D12FA6600EFC06A /* WooShipping Customs */, CE6E11092C91DA3D00563DD4 /* WooShipping Items Section */, CE7B4A592CA1BF7800F764EB /* WooShipping Hazmat Section */, CECEFA6B2CA2CE990071C7DB /* WooShipping Package and Rate Selection */, + CE7FE6922D13249D000F9475 /* WooShippingAddresses */, CEAEB7582CD0F96800C3E117 /* WooShipping Post-Purchase */, CEAB739B2C81E3F600A7EB39 /* WooShippingCreateLabelsView.swift */, CEC3CC6E2C93146700B93FBE /* WooShippingCreateLabelsViewModel.swift */, @@ -12062,9 +12205,12 @@ CEC3CC7B2C94A06500B93FBE /* WooShippingItemsDataSourceTests.swift */, CE49C4762CBEC8BA00EA5C84 /* WooShipping_ShippingLineViewModelTests.swift */, DA40806D2CC29650002A4577 /* WooShippingAddCustomPackageViewModelTests.swift */, + DA24152A2D116EA90008F69A /* WooShippingAddPackageViewModelTests.swift */, CE86C8322CC8F9BB00B1764D /* WooShippingServiceCardViewModelTests.swift */, CE315DC72CC942A200A06748 /* WooShippingServiceViewModelTests.swift */, CE4AFE472CD239B90013C52B /* WooShippingPostPurchaseViewModelTests.swift */, + CE7269C72D11A99800D565C1 /* WooShippingAddPackageViewModelTests.swift */, + CE56C01D2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift */, ); path = "WooShipping Create Shipping Labels"; sourceTree = ""; @@ -12117,11 +12263,11 @@ CEE125502CC66C8100D3183D /* WooShippingServiceCardViewModel.swift */, DA4080712CC2967C002A4577 /* WooShippingAddCustomPackageViewModel.swift */, DAF689E02CD23941008B8398 /* WooAddCustomPackageView.swift */, - CEE1138E2CFA2D8900F53E30 /* PackageOptionView.swift */, + CEE1138E2CFA2D8900F53E30 /* WooShippingPackageOptionView.swift */, DA4080732CC2A7BC002A4577 /* WooCarrierPackagesSelectionView.swift */, DA4080742CC2A7BC002A4577 /* WooSavedPackagesSelectionView.swift */, DAAF53B72CF75701006D8880 /* WooShippingAddPackageViewModel.swift */, - CEE113942CFA2F7700F53E30 /* SelectedPackageView.swift */, + CEE113942CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift */, ); path = "WooShipping Package and Rate Selection"; sourceTree = ""; @@ -12475,6 +12621,7 @@ D8815ADE26383EE700EDAD62 /* CardPresentPaymentsModalViewController.xib */, 03E471BD29388787001A58AD /* BuiltInCardReaderConnectionController.swift */, 03BB9EA4292E2D0C00251E9E /* CardReaderConnectionController.swift */, + 013D2FB32CFEFEA800845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift */, 035DBA46292D0994003E5125 /* CardPresentPaymentPreflightController.swift */, D8EE9690264D328A0033B2F9 /* LegacyReceiptViewController.swift */, D8EE9691264D328A0033B2F9 /* LegacyReceiptViewController.xib */, @@ -12490,6 +12637,8 @@ D8815AE526383FC200EDAD62 /* CardPresentPayments */ = { isa = PBXGroup; children = ( + 011D396E2D09FCCB00DB1445 /* CardPresentModalLocationRequired.swift */, + 01BB6C062D09DC470094D55B /* CardPresentModalLocationPreAlert.swift */, D8815AE626383FD600EDAD62 /* CardPresentPaymentsModalViewModel.swift */, 0396CFAC2981476800E91436 /* CardPresentModalBuiltInConnectingFailed.swift */, 032E481C2982996E00469D92 /* CardPresentModalBuiltInConnectingFailedNonRetryable.swift */, @@ -13136,6 +13285,16 @@ path = Themes; sourceTree = ""; }; + DEE2152E2D113F61004A11F3 /* EditStoreList */ = { + isa = PBXGroup; + children = ( + DEE215332D1297CD004A11F3 /* UserDefaults+EditStoreList.swift */, + DEE2152F2D113F84004A11F3 /* EditStoreListViewModel.swift */, + DEE2152C2D113EF1004A11F3 /* EditStoreListView.swift */, + ); + path = EditStoreList; + sourceTree = ""; + }; DEE4BBCA27FED9390002C818 /* ProductListSelector */ = { isa = PBXGroup; children = ( @@ -14850,6 +15009,7 @@ EE57C11F297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift in Sources */, 20D2CCA32C7E175700051705 /* WavesProgressViewStyle.swift in Sources */, 02E4908929AE49B9005942AE /* TopPerformersEmptyView.swift in Sources */, + 20D4AE012D133B43004555B2 /* ItemsStackState.swift in Sources */, 4592A54B24BF58DD00BC3DE0 /* ProductTagsViewController.swift in Sources */, D817585E22BB5E8700289CFE /* OrderEmailComposer.swift in Sources */, DEB2D2E82C92B00400ACD75D /* CollapsibleHStack.swift in Sources */, @@ -14866,6 +15026,7 @@ DE50294D28BEF8F100551736 /* JetpackConnectionWebViewModel.swift in Sources */, DEF8CF0A29A71EE600800A60 /* JetpackSetupCoordinator.swift in Sources */, 02645D8827BA2E820065DC68 /* NSAttributedString+Attributes.swift in Sources */, + CECAE70C2D22EFA4000AE10B /* WooShippingOriginAddressListViewModel.swift in Sources */, DE2004622BF70A6600660A72 /* ProductStockDashboardCardViewModel.swift in Sources */, 03B9E5292A14F136005C77F5 /* SilenceablePassthroughCardPresentPaymentAlertsPresenter.swift in Sources */, 024DF3092372CA00006658FE /* EditorViewProperties.swift in Sources */, @@ -14886,6 +15047,7 @@ B509FED121C041DF000076A9 /* Locale+Woo.swift in Sources */, B5DB01B52114AB2D00A4F797 /* WooCrashLoggingStack.swift in Sources */, 205B7EBF2C19FBCA00D14A36 /* PointOfSaleCardPresentPaymentRequiredReaderUpdateInProgressAlertViewModel.swift in Sources */, + 027ADB6E2D1BF5E3009608DB /* ParentProductCardView.swift in Sources */, 26ED9660274328BC00FA00A1 /* SimplePaymentsSummaryViewModel.swift in Sources */, 024DF31E23743045006658FE /* TextList+AztecFormatting.swift in Sources */, CEEF742E2B9A0BAA00B03948 /* SessionsReportCardViewModel.swift in Sources */, @@ -14912,7 +15074,7 @@ E11228BE2707267F004E9F2D /* CardPresentModalUpdateFailedNonRetryable.swift in Sources */, 86DE68822B4BA47A00B437A6 /* BlazeAdDestinationSettingViewModel.swift in Sources */, EEC5E01129A70CC300416CAC /* StoreSetupProgressView.swift in Sources */, - 683AC4AC2CEF019A00FF0A5E /* POSSendReceiptModalView.swift in Sources */, + 683AC4AC2CEF019A00FF0A5E /* POSSendReceiptView.swift in Sources */, EE3E9E912B05FE0700985B2C /* SubscriptionExpiryViewModel.swift in Sources */, B9C4AB2728002AF3007008B8 /* PaymentReceiptEmailParameterDeterminer.swift in Sources */, 5739D2D426274D580020E737 /* NoSecureConnectionErrorViewModel.swift in Sources */, @@ -14935,6 +15097,7 @@ 02E8B17A23E2C4BD00A43403 /* CircleSpinnerView.swift in Sources */, CCE4CD282669324300E09FD4 /* ShippingLabelPaymentMethodsTopBanner.swift in Sources */, 269B46622A16D68A00ADA872 /* UpdateAnalyticsSettingsUseCase.swift in Sources */, + 019630B42D01DB4800219D80 /* TapToPayAwarenessMomentView.swift in Sources */, B95B15C92B15EBA000A54044 /* UpdateProductInventoryViewModel.swift in Sources */, 02CA63DD23D1ADD100BBF148 /* MediaPickingContext.swift in Sources */, EE3D1E942B8EC1E00016B132 /* BlazeCampaignListItem+Customizations.swift in Sources */, @@ -14958,6 +15121,7 @@ 02ACD25A2852E11700EC928E /* CloseAccountCoordinator.swift in Sources */, 0191301B2CF4E782008C0C88 /* TapToPayEducationStepViewModel.swift in Sources */, 0258D9492B68E7FE00D280D0 /* ProductsSplitViewWrapperController.swift in Sources */, + 019630B62D02018C00219D80 /* TapToPayAwarenessMomentDeterminer.swift in Sources */, 036CA6F129229C9E00E4DF4F /* IndefiniteCircularProgressViewStyle.swift in Sources */, 451A9973260E39270059D135 /* ShippingLabelPackageNumberRow.swift in Sources */, AEE2610F26E664CE00B142A0 /* EditOrderAddressFormViewModel.swift in Sources */, @@ -14986,6 +15150,7 @@ 029F29FC24D94106004751CA /* EditableProductVariationModel.swift in Sources */, 0218B4EC242E06F00083A847 /* MediaType+WPMediaType.swift in Sources */, D85A3C5026C153A500C0E026 /* InPersonPaymentsPluginNotActivatedView.swift in Sources */, + B90D217E2D1ECA3100ED60ED /* WooShippingCustomsItemDescriptionInfoDialog.swift in Sources */, D8815AE726383FD600EDAD62 /* CardPresentPaymentsModalViewModel.swift in Sources */, DAB4099F2CA5A329008EE1F2 /* WooShippingAddPackageView.swift in Sources */, 02B21C5729C9EEF900C5623B /* WooAnalyticsEvent+StoreOnboarding.swift in Sources */, @@ -15023,6 +15188,7 @@ CC13C0CB278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift in Sources */, 020732042988AB7B000A53C2 /* DomainContactInfoForm.swift in Sources */, 026826AD2BF59DF70036F959 /* PointOfSaleDashboardView.swift in Sources */, + DEE215302D113F89004A11F3 /* EditStoreListViewModel.swift in Sources */, DE2FE595292737330018040A /* JetpackSetupView.swift in Sources */, B59D1EEA2190AE96009D1978 /* StorageNote+Woo.swift in Sources */, CE7F778B2C074D2500C89F4E /* EditableOrderShippingLineViewModel.swift in Sources */, @@ -15064,6 +15230,7 @@ 20ADE9412C6A02B700C91265 /* POSErrorXMark.swift in Sources */, DE63115B2AF1E13200587641 /* WPComLoginCoordinator.swift in Sources */, EE3B17B62AA03837004D3E0C /* CelebrationView.swift in Sources */, + 0291497D2D26CB2500F7B3B3 /* ChildItemList.swift in Sources */, D85A3C5226C15DE200C0E026 /* InPersonPaymentsPluginNotSupportedVersionView.swift in Sources */, EE57C11D297AC27300BC31E7 /* TrackEventRequestNotificationHandler.swift in Sources */, DE5746312B43F6180034B10D /* BlazeCampaignCreationFormViewModel.swift in Sources */, @@ -15073,6 +15240,7 @@ CE583A0B2107937F00D73C1C /* TextViewTableViewCell.swift in Sources */, 45AE150224A23F03005AA948 /* ProductParentCategoriesViewController.swift in Sources */, CE6302412BAAFB5E00E3325C /* CustomersListViewModel.swift in Sources */, + B90DD08E2D12FAA400EFC06A /* WooShippingCustomsRow.swift in Sources */, B557652B20F681E800185843 /* StoreTableViewCell.swift in Sources */, 029700EC24FE38C900D242F8 /* ScrollWatcher.swift in Sources */, D8C11A4E22DD235F00D4A88D /* OrderDetailsResultsControllers.swift in Sources */, @@ -15118,6 +15286,7 @@ 31C21FA426D9949000916E2E /* SeveralReadersFoundViewController.swift in Sources */, 205B7ED12C19FD8500D14A36 /* PointOfSaleCardPresentPaymentErrorMessageViewModel.swift in Sources */, CECEFA6D2CA2CEB50071C7DB /* WooShippingPackageAndRatePlaceholder.swift in Sources */, + 013D2FB62CFF54BB00845D75 /* TapToPayEducationStepsFactory.swift in Sources */, 02E3B62829026C8F007E0F13 /* AccountCreationForm.swift in Sources */, CEE125512CC66C8700D3183D /* WooShippingServiceCardViewModel.swift in Sources */, CC078531266E706300BA9AC1 /* ErrorTopBannerFactory.swift in Sources */, @@ -15159,6 +15328,7 @@ CC4A4ED82655478D00B75DCD /* ShippingLabelPaymentMethodsViewModel.swift in Sources */, B5A8F8A920B84D3F00D211DE /* ApiCredentials.swift in Sources */, 02305352237454C700487A64 /* AztecHorizontalRulerFormatBarCommand.swift in Sources */, + B90D217C2D1B06F700ED60ED /* WooShippingCustomsItemViewModel.swift in Sources */, EE3B17AF2A9EFE97004D3E0C /* WooPaymentsSetupInstructionsView.swift in Sources */, 02FCA5542B54FC8C0097BFB8 /* CardPresentPaymentOnboardingState+Analytics.swift in Sources */, B5DBF3C520E148E000B53AED /* DeauthenticatedState.swift in Sources */, @@ -15195,6 +15365,8 @@ 866016512B47F8F800B4047E /* ProductSelector+Blaze.swift in Sources */, 456417F4247D5434001203F6 /* UITableView+Helpers.swift in Sources */, E15FC74326BC1D2700CF83E6 /* SafariSheet.swift in Sources */, + 011D396F2D09FCD200DB1445 /* CardPresentModalLocationRequired.swift in Sources */, + 20F7B12D2D12C7B900C08193 /* ItemsContainerState.swift in Sources */, 459DB7D52673721300E2CAD2 /* TopLoaderView.swift in Sources */, 02BC5AA024D27D8E00C43326 /* ProductVariationFormViewModel.swift in Sources */, 7E7C5F832719A93C00315B61 /* ProductCategoryListViewModel.swift in Sources */, @@ -15203,6 +15375,7 @@ 0282DD94233C9465006A5FDB /* SearchUICommand.swift in Sources */, 2004E2EB2C0E219D00D62521 /* CardPresentPaymentPreflightAdaptor.swift in Sources */, 011D7A332CEC877A0007C187 /* CardPresentModalNonRetryableErrorEmailSent.swift in Sources */, + 68AF3C3B2D01481C006F1ED2 /* POSReceiptEligibilityBanner.swift in Sources */, EEBA02A32ADD6005001FE8E4 /* BlazeCampaignDashboardView.swift in Sources */, 028BAC4722F3B550008BB4AF /* StatsTimeRangeV4+UI.swift in Sources */, 024DF32123744798006658FE /* AztecFormatBarCommandCoordinator.swift in Sources */, @@ -15219,6 +15392,7 @@ B58B4AB22108F01700076FDD /* NoticeView.swift in Sources */, 20D2CCA52C7E328300051705 /* POSModalCloseButton.swift in Sources */, DA25ADDD2C86145E00AE81FE /* MarkOrderAsReadUseCase.swift in Sources */, + 01BB6C072D09DC560094D55B /* CardPresentModalLocationPreAlert.swift in Sources */, 74B5713621CD7604008F9B8E /* SharingHelper.swift in Sources */, 261F1A7929C2AB2E001D9861 /* FreeTrialBannerViewModel.swift in Sources */, DEF657A82C895B0500ACD61E /* BlazeCampaignObjectivePickerViewModel.swift in Sources */, @@ -15258,7 +15432,7 @@ B98DA0AE2B275F45008A3607 /* ProductLoaderView.swift in Sources */, 02B1AFEC24BC5AE5005DB1E3 /* LinkedProductListSelectorDataSource.swift in Sources */, 0206E296299CD2C900C061C1 /* WooAnalyticsEvent+DomainSettings.swift in Sources */, - 026826AC2BF59DF70036F959 /* ItemCardView.swift in Sources */, + 026826AC2BF59DF70036F959 /* SimpleProductCardView.swift in Sources */, 26F94E1C267A3E4500DB6CCF /* ProductAddOnsListViewController.swift in Sources */, 451A04EC2386D2B300E368C9 /* ProductImagesCollectionViewDataSource.swift in Sources */, 02DD81F9242CAA400060E50B /* WordPressMediaLibraryPickerViewController.swift in Sources */, @@ -15338,6 +15512,7 @@ 02490D1A284DE664002096EF /* ProductImagesSaver.swift in Sources */, 8646A9BA2B46C7CA001F606C /* BlazeAdDestinationSettingView.swift in Sources */, 205E794F2C207D38001BA266 /* PointOfSaleCardPresentPaymentNonRetryableErrorMessageView.swift in Sources */, + 027ADB752D218A8D009608DB /* POSItemCardBorderStylesModifier.swift in Sources */, 0215320B24231D5A003F2BBD /* UIStackView+Subviews.swift in Sources */, 02F4F50B237AEB8A00E13A9C /* ProductFormTableViewDataSource.swift in Sources */, 2004E2CC2C07795E00D62521 /* CardPresentPaymentError.swift in Sources */, @@ -15350,7 +15525,7 @@ 028296EC237D28B600E84012 /* TextViewViewController.swift in Sources */, 02B8650F24A9E2D800265779 /* Product+SwiftUIPreviewHelpers.swift in Sources */, 203163B92C1C5F42001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdateAddressView.swift in Sources */, - CEE113952CFA2F7700F53E30 /* SelectedPackageView.swift in Sources */, + CEE113952CFA2F7700F53E30 /* WooShippingSelectedPackageView.swift in Sources */, 454B28BE23BF63C600CD2091 /* DateIntervalFormatter+Helpers.swift in Sources */, AE6DBE3B2732CAAD00957E7A /* AdaptiveStack.swift in Sources */, 03A6C18628B8CC7F00AADF23 /* InPersonPaymentsOnboardingErrorButtonViewModel.swift in Sources */, @@ -15582,6 +15757,7 @@ 45B9C64323A91CB6007FC4C5 /* PriceInputFormatter.swift in Sources */, E1F52DC62668E03B00349D75 /* CardPresentModalBluetoothRequired.swift in Sources */, CE29A63029F2DACC003D2A00 /* OrderSubscriptionTableViewCell.swift in Sources */, + 68A345642D029E12002EE324 /* PaymentButtons.swift in Sources */, DE1B030D268DD01A00804330 /* ReviewOrderViewController.swift in Sources */, 203163AF2C1C5C6B001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdateAddressAlertViewModel.swift in Sources */, EE45E2BF2A409E250085F227 /* UIColor+Tooltip.swift in Sources */, @@ -15653,7 +15829,6 @@ 02E8B17C23E2C78A00A43403 /* ProductImageStatus.swift in Sources */, 03F5CB832A0C3A1A0026877A /* AnimatedPlaceholder.swift in Sources */, 0259D5FF2581F3FA003B1CD6 /* ShippingLabelPaperSizeOptionsViewController.swift in Sources */, - CCEC256A27B581E800EF9FA3 /* ProductVariationFormatter.swift in Sources */, 02EA6BFA2435E92600FFF90A /* KingfisherImageDownloader+ImageDownloadable.swift in Sources */, 7E7C5F8F2719BA7300315B61 /* ProductCategoryCellViewModel.swift in Sources */, DE8AA0B32BBE55E40084D2CC /* DashboardViewHostingController.swift in Sources */, @@ -15694,6 +15869,7 @@ EE19058C2B5F744300617C53 /* BlazeAddPaymentMethodWebView.swift in Sources */, D83F5933225B2EB900626E75 /* ManualTrackingViewController.swift in Sources */, 3142663F2645E2AB00500598 /* PaymentSettingsFlowViewModelPresenter.swift in Sources */, + 68E141DB2D13107400A70D5B /* PointOfSaleCollectCashView.swift in Sources */, DEDB886B26E8531E00981595 /* ShippingLabelPackageAttributes.swift in Sources */, AEC95D412774C5AE001571F5 /* AddressFormViewModelProtocol.swift in Sources */, B6E851F5276330200041D1BA /* RefundCustomAmountsDetailsTableViewCell.swift in Sources */, @@ -15737,12 +15913,14 @@ 268D7C9C2984752A00D38709 /* SupportForm.swift in Sources */, 02A275BA23FE50AA005C560F /* ProductUIImageLoader.swift in Sources */, 02305353237454C700487A64 /* AztecInsertMoreFormatBarCommand.swift in Sources */, + 02335E492D13BA42000B6ECE /* AsyncPaginationTracker.swift in Sources */, B5D6DC54214802740003E48A /* SyncCoordinator.swift in Sources */, 205B7EBD2C19FB6600D14A36 /* PointOfSaleCardPresentPaymentFoundReaderAlertViewModel.swift in Sources */, 018D5C7E2CA6B4A60085EBEE /* CurrencySettings+Sanitized.swift in Sources */, B57C5C9421B80E4700FF82B2 /* Data+Woo.swift in Sources */, 453A907925EFB6D6006EE892 /* ButtonActivityIndicator.swift in Sources */, 4546E09A271F0942003836F3 /* FilteredOrdersHeaderBar.swift in Sources */, + 0291497B2D2682FF00F7B3B3 /* ItemList.swift in Sources */, 02312797277D4F650060E180 /* StoreStatsPeriodViewModel.swift in Sources */, B554E17B2152F27200F31188 /* UILabel+Appearance.swift in Sources */, 020B2F8F23BD9F1F00BD79AD /* IntegerInputFormatter.swift in Sources */, @@ -15754,6 +15932,7 @@ 0260B1B12805321B00FCFE8C /* OrderDetailsPaymentAlertsProtocol.swift in Sources */, 4556ED38270645A6005CBC0D /* ShippingLabelCarrierSectionHeader.swift in Sources */, 267C01CF29E89E1700FCC97B /* StorePlanSynchronizer.swift in Sources */, + 013D2FB42CFEFEC600845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift in Sources */, B541B21C2189F3D8008FE7C1 /* StringStyles.swift in Sources */, B58B4AB82108F14700076FDD /* NoticeNotificationInfo.swift in Sources */, DA4080722CC2967C002A4577 /* WooShippingAddCustomPackageViewModel.swift in Sources */, @@ -15937,6 +16116,7 @@ DEC2962726C17AD8005A056B /* ShippingLabelCustomsForm+Localization.swift in Sources */, 26A630FE253F63C300CBC3B1 /* RefundableOrderItem.swift in Sources */, CEE006052077D1280079161F /* SummaryTableViewCell.swift in Sources */, + DEE215342D1297CD004A11F3 /* UserDefaults+EditStoreList.swift in Sources */, CE63024E2BAC664900E3325C /* EmailView.swift in Sources */, DE4B3B5826A7041800EEF2D8 /* EdgeInsets+Woo.swift in Sources */, 02CE4304276993DA0006EAEF /* CaptureDevicePermissionChecker.swift in Sources */, @@ -16018,6 +16198,7 @@ DEF8CF0F29A890E900800A60 /* JetpackBenefitsViewModel.swift in Sources */, CE6E110B2C91DA5D00563DD4 /* WooShippingItemRow.swift in Sources */, B946880E29B627EB000646B0 /* SearchableActivityConvertable.swift in Sources */, + 027ADB732D21812D009608DB /* POSItemImageView.swift in Sources */, EE09DE0B2C2D6E5100A32680 /* SelectPackageImageCoordinator.swift in Sources */, DE621F6A29D67E1B000DE3BD /* WooAnalyticsEvent+JetpackSetup.swift in Sources */, DE78DE422B2813E4002E58DE /* ThemesCarouselViewModel.swift in Sources */, @@ -16032,6 +16213,7 @@ 261AA30C2753119E009530FE /* PaymentMethodsViewModel.swift in Sources */, 68E674AD2A4DAC010034BA1E /* CurrentPlanDetailsView.swift in Sources */, DE68B81F26F86B1700C86CFB /* OfflineBannerView.swift in Sources */, + B90D21802D1ED1F300ED60ED /* WooShippingCustomsItemOriginCountryInfoDialog.swift in Sources */, D8610BCC256F284700A5DF27 /* ULErrorViewModel.swift in Sources */, CCFC50552743BC0D001E505F /* OrderForm.swift in Sources */, 262418332B8D3630009A3834 /* ApplicationPasswordTutorial.swift in Sources */, @@ -16049,7 +16231,7 @@ 029B0F57234197B80010C1F3 /* ProductSearchUICommand.swift in Sources */, DE19BB0C26C2688B00AB70D9 /* SingleSelectionList.swift in Sources */, 261B526E29B795DB00DF7AB6 /* SupportFormMetadataProvider.swift in Sources */, - CEE1138F2CFA2D8900F53E30 /* PackageOptionView.swift in Sources */, + CEE1138F2CFA2D8900F53E30 /* WooShippingPackageOptionView.swift in Sources */, 2004E2C42C076D3800D62521 /* CardPresentPaymentEvent.swift in Sources */, 68ED2BD62ADD2C8C00ECA88D /* LineDetailView.swift in Sources */, 45DB705A26124C710064A6CF /* TitleAndTextFieldRow.swift in Sources */, @@ -16279,6 +16461,7 @@ DECEA4472C81778300C28C10 /* ProductImagePickerView.swift in Sources */, 26E0AE1926335AA900A5EB3B /* Survey.swift in Sources */, 0371C3682875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift in Sources */, + B90D21782D15B72900ED60ED /* WooShippingCustomsFormViewModel.swift in Sources */, 03EF24FA28BF5D21006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModel.swift in Sources */, 016C6B972C74AB17000D86FD /* POSConnectivityView.swift in Sources */, EE45E2B72A409BA40085F227 /* Tooltip.swift in Sources */, @@ -16310,6 +16493,7 @@ DE86E9272A4BEA2500A89A5B /* FeedbackView.swift in Sources */, CC72BB6427BD842500837876 /* DisclosureIndicator.swift in Sources */, 77E53EC52510C193003D385F /* ProductDownloadListViewController+Droppable.swift in Sources */, + 01BB6C0A2D09E9630094D55B /* LocationService.swift in Sources */, 3F50FE4328CAEBA800C89201 /* AppLocalizedString.swift in Sources */, 0259D5F92581F0E6003B1CD6 /* ShippingLabelPaperSizeOptionView.swift in Sources */, D81F2D37225F0D160084BF9C /* EmptyListMessageWithActionView.swift in Sources */, @@ -16342,6 +16526,7 @@ 2667BFE52530DCF4008099D4 /* RefundItemsValuesCalculationUseCase.swift in Sources */, 203163BB2C1C5F72001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedUpdatePostalCodeView.swift in Sources */, CEC3CC6B2C92FDB700B93FBE /* WooShippingItemRowViewModel.swift in Sources */, + B90D217A2D1B06D000ED60ED /* WooShippingCustomsItem.swift in Sources */, AEE9A880293A3E5500227C92 /* RefreshablePlainList.swift in Sources */, 203163B72C1C5EDF001C96DA /* PointOfSaleCardPresentPaymentConnectingFailedChargeReaderView.swift in Sources */, 2004E2ED2C0F5DD800D62521 /* CardPresentPaymentCollectOrderPaymentUseCaseAdaptor.swift in Sources */, @@ -16395,11 +16580,13 @@ 026CAF7E2AC2B76C002D23BB /* ConfigurableBundleProductViewModel.swift in Sources */, 4515C88D25D6BE540099C8E3 /* ShippingLabelAddressFormViewController.swift in Sources */, CE5F462A23AACA0A006B1A5C /* RefundDetailsDataSource.swift in Sources */, + CE7FE6942D1324BD000F9475 /* WooShippingOriginAddressListView.swift in Sources */, B9B7E37E2AF105EF00A959CA /* PencilEditButton.swift in Sources */, 02162726237963AF000208D2 /* ProductFormViewController.swift in Sources */, 0313651728ACE9F400EEE571 /* InPersonPaymentsCashOnDeliveryPaymentGatewayNotSetUpViewModel.swift in Sources */, 571CDD5A250ACC470076B8CC /* UITableViewDiffableDataSource+Helpers.swift in Sources */, DE7E5E7D2B4BB617002E28D2 /* BlazeTargetLanguagePickerView.swift in Sources */, + 029149782D26658A00F7B3B3 /* VariationCardView.swift in Sources */, EE09DE082C2C0CA100A32680 /* ProductCreationAIStartingInfoViewModel.swift in Sources */, 02B1AA6529A4705A00D54FCB /* TabbedViewController.swift in Sources */, 4580BA7423F192D400B5F764 /* ProductSettingsViewController.swift in Sources */, @@ -16454,6 +16641,7 @@ 02ADC7CC239762E0008D4BED /* PaginatedListSelectorViewProperties.swift in Sources */, 03FBDAF2263EE47C00ACE257 /* CouponListViewModel.swift in Sources */, 458BAC6E2C57CDA6009440EA /* ProductPasswordEligibilityUseCase.swift in Sources */, + DEE2152D2D113EF1004A11F3 /* EditStoreListView.swift in Sources */, 0388E1A829E04687007DF84D /* DeepLinkNavigator.swift in Sources */, B6A10E9C292E5DEE00790797 /* AnalyticsTimeRangeCardViewModel.swift in Sources */, 26CCBE0B2523B3650073F94D /* RefundProductsTotalTableViewCell.swift in Sources */, @@ -16481,6 +16669,7 @@ CE2A9FC823C3D2D4002BEC1C /* RefundedProductsDataSource.swift in Sources */, CECC759523D6057E00486676 /* OrderItem+Woo.swift in Sources */, 039B7E6529F167DB00E21EF4 /* UniversalLinkRouter+JustInTimeMessages.swift in Sources */, + 20F7B12F2D12CBE700C08193 /* ItemsViewState.swift in Sources */, 03AFDE02282C0B82003B67CD /* InPersonPaymentsCompletedView.swift in Sources */, DEF657A62C895AE900ACD61E /* BlazeCampaignObjectivePickerView.swift in Sources */, 0191301D2CF4E7B7008C0C88 /* TapToPayEducationViewModel.swift in Sources */, @@ -16568,6 +16757,7 @@ DE6F997E2BEE00C50007B2DD /* InboxDashboardCard.swift in Sources */, 4590B6A8261F0F8300A6FCE0 /* SegmentedView.swift in Sources */, DE77889826FCA39B008DFF44 /* TitleAndSubtitleRow.swift in Sources */, + B90D21762D14269200ED60ED /* WooShippingCustomsForm.swift in Sources */, B6930BDA293FD1EE00C6FFDB /* AnalyticsHubLastQuarterRangeData.swift in Sources */, 26F115AF2C49A9250019CD73 /* DashboardSyncBackgroundTask.swift in Sources */, 02DE5CA9279F857D007CBEF3 /* Double+Rounding.swift in Sources */, @@ -16586,6 +16776,7 @@ 021FB44C24A5E3B00090E144 /* ProductListMultiSelectorSearchUICommand.swift in Sources */, 456C7EEB25EE71F10016CBC6 /* ShippingLabelSuggestedAddressViewController.swift in Sources */, D89CFE9025B256E9000E4683 /* ULAccountMatcher.swift in Sources */, + CECAE7112D23168D000AE10B /* WooShippingOriginAddress+Woo.swift in Sources */, DE3404E828B4B96800CF0D97 /* NonAtomicSiteViewModel.swift in Sources */, D8815B0126385E3F00EDAD62 /* CardPresentModalTapCard.swift in Sources */, 773077EE251E943700178696 /* ProductDownloadFileViewController.swift in Sources */, @@ -16650,6 +16841,7 @@ 86F5FFE42CA30D9200C767C4 /* CustomFieldsListViewModelTests.swift in Sources */, 0261F5A728D454CF00B7AC72 /* ProductSearchUICommandTests.swift in Sources */, 098FFA1727AD7F5D002EBEE4 /* OrderStatusListDataSourceTests.swift in Sources */, + DA24152B2D116EAE0008F69A /* WooShippingAddPackageViewModelTests.swift in Sources */, DE19BB1D26C6911900AB70D9 /* ShippingLabelCustomsFormListViewModelTests.swift in Sources */, D449C52C26E02F2F00D75B02 /* WhatsNewFactoryTests.swift in Sources */, 5761298B24589B84007BB2D9 /* NumberFormatter+LocalizedOrNinetyNinePlusTests.swift in Sources */, @@ -16857,6 +17049,7 @@ B53A569B21123E8E000776C9 /* MockTableView.swift in Sources */, CE7F778D2C0770FF00C89F4E /* EditableOrderShippingLineViewModelTests.swift in Sources */, 2667BFE3252FA695008099D4 /* RefundItemQuantityListSelectorCommandTests.swift in Sources */, + 019630B82D0211F400219D80 /* TapToPayAwarenessMomentDeterminerTests.swift in Sources */, DEBAB70F2A7A6F3800743185 /* MockConnectivityObserver.swift in Sources */, 02B191542CCF377E00CF38C9 /* PointOfSaleCardPresentPaymentOnboardingViewModelTests.swift in Sources */, EEEA41F22869A5F400AEFC4B /* MockProductImagesProductIDUpdater.swift in Sources */, @@ -16875,6 +17068,7 @@ EEB221A729B9B5B300662A12 /* CouponLineDetailsViewModelTests.swift in Sources */, 026D684B2A0E0A9600D8C22C /* LocalNotificationSchedulerTests.swift in Sources */, 02038C612AF222D600CD36D9 /* ConfigurableVariableBundleAttributePickerViewModelTests.swift in Sources */, + CE56C01E2D2431C000EBDE24 /* WooShippingOriginAddressListViewModelTests.swift in Sources */, DA3F99BA2C92F6D30034BDA5 /* MarkOrderAsReadUseCaseTests.swift in Sources */, 26C0D1E32B460E5700F6EDA5 /* OrderNotificationViewModel.swift in Sources */, 86E40AED2B597DEC00990365 /* BlazeCampaignCreationCoordinatorTests.swift in Sources */, @@ -16960,10 +17154,12 @@ DEF13C542963ED4E0024A02B /* PostSiteCredentialLoginCheckerTests.swift in Sources */, EEC2D281292D10520072132E /* SiteCredentialLoginHostingViewControllerTests.swift in Sources */, 746FC23D2200A62B00C3096C /* DateWooTests.swift in Sources */, + 6801E4172D0FFF0300F9DF46 /* MockReceiptService.swift in Sources */, DEF8CF1129A8933E00800A60 /* JetpackBenefitsViewModelTests.swift in Sources */, 31F21B5A263CB41A0035B50A /* MockCardPresentPaymentsStoresManager.swift in Sources */, 86F0896F2B307D7E00D668A1 /* ThemesPreviewViewModelTests.swift in Sources */, CC3B35DF28E5BE6F0036B097 /* ReviewReplyViewModelTests.swift in Sources */, + DEE215322D116FBB004A11F3 /* EditStoreListViewModelTests.swift in Sources */, DE4D23A029B09D71003A4B5D /* WPComMagicLinkViewModelTests.swift in Sources */, 02F5F80E246102240000613A /* FilterProductListViewModel+numberOfActiveFiltersTests.swift in Sources */, 021AEF9C2407B07300029D28 /* ProductImageStatus+HelpersTests.swift in Sources */, @@ -17055,6 +17251,7 @@ DEDA8DC02B19CDC50076BF0F /* ThemeSettingViewModelTests.swift in Sources */, CE7CEC2D2C2EF0E50066FD53 /* GoogleAdsCampaignReportCardViewModelTests.swift in Sources */, 03EF250028C0E9EE006A033E /* InPersonPaymentsCashOnDeliveryToggleRowViewModelTests.swift in Sources */, + 01F067ED2D0C5D59001C5805 /* MockLocationService.swift in Sources */, 86B3E2572C6B249C0002420B /* HelpAndSupportViewModelTests.swift in Sources */, DE2FE5832924DA2F0018040A /* JetpackSetupRequiredViewModelTests.swift in Sources */, AEB4DB99290AE8F300AE4340 /* MockCookieJar.swift in Sources */, @@ -17090,6 +17287,7 @@ 025678C725773399009D7E6C /* Collection+ShippingLabelTests.swift in Sources */, 02BC5AA624D27F8900C43326 /* ProductVariationFormViewModel+ChangesTests.swift in Sources */, 02CD3BFE2C35D04C00E575C4 /* MockCardPresentPaymentService.swift in Sources */, + 203AB2A82D01B988001D989C /* OrderCustomAmountsSectionViewModelTests.swift in Sources */, EE8A30472B74F3A8001D7C66 /* OrderAttributionInfo+OriginTests.swift in Sources */, 03A6C18428B52B1500AADF23 /* InPersonPaymentsCashOnDeliveryPaymentGatewayNotSetUpViewModelTests.swift in Sources */, DE74A44F2BCE2FCF0009C415 /* StorePerformanceViewModelTests.swift in Sources */, @@ -17150,6 +17348,7 @@ DE001323279A793A00EB0350 /* CouponWooTests.swift in Sources */, 45B98E1F25DECC1C00A1232B /* ShippingLabelAddressFormViewModelTests.swift in Sources */, EE4C75DF2C86D2F500F9D860 /* BlazeLocalNotificationSchedulerSpy.swift in Sources */, + 011D39712D0A324200DB1445 /* LocationServiceTests.swift in Sources */, 028E1F722833E954001F8829 /* DashboardViewModelTests.swift in Sources */, 02BC5AA424D27F8900C43326 /* ProductVariationFormViewModel+ObservablesTests.swift in Sources */, CCCC5B1326CC2B9F0034FB63 /* ShippingLabelCustomPackageFormViewModelTests.swift in Sources */, @@ -17256,6 +17455,7 @@ 7E7C5F8B2719AEDA00315B61 /* EditProductCategoryListViewModelTests.swift in Sources */, 0247F510286E7D26009C177E /* ProductVariationFormViewModel+ImageUploaderTests.swift in Sources */, 020B2F9123BDD71500BD79AD /* IntegerInputFormatterTests.swift in Sources */, + CE7269C82D11A99800D565C1 /* WooShippingAddPackageViewModelTests.swift in Sources */, DECEA4492C81C1A800C28C10 /* ProductImagePickerViewModelTests.swift in Sources */, D816DDBC22265DA300903E59 /* OrderTrackingTableViewCellTests.swift in Sources */, 579CDF01274D811D00E8903D /* StoreStatsUsageTracksEventEmitterTests.swift in Sources */, @@ -17334,7 +17534,7 @@ 80089F182949B92D0078C671 /* ProductFlow.swift in Sources */, 800A5B58275483D6009DE2CD /* OrdersTests.swift in Sources */, 80C5D4E729ED0C5E00352EC7 /* TestStrings.swift in Sources */, - 80AD2CA427858BAB00A63DE8 /* StatsTests.swift in Sources */, + 80AD2CA427858BAB00A63DE8 /* DashboardTests.swift in Sources */, 800A5BCB2759CE4B009DE2CD /* ProductsTests.swift in Sources */, 80C5D4E329ECFFE400352EC7 /* SupportsTests.swift in Sources */, 800A5B9E275623FC009DE2CD /* LoginFlow.swift in Sources */, diff --git a/WooCommerce/WooCommerce.xcodeproj/xcshareddata/xcschemes/WooCommerce.xcscheme b/WooCommerce/WooCommerce.xcodeproj/xcshareddata/xcschemes/WooCommerce.xcscheme index 33bfadd0534..4499be43116 100644 --- a/WooCommerce/WooCommerce.xcodeproj/xcshareddata/xcschemes/WooCommerce.xcscheme +++ b/WooCommerce/WooCommerce.xcodeproj/xcshareddata/xcschemes/WooCommerce.xcscheme @@ -125,7 +125,7 @@ + isEnabled = "YES"> Bool { isSiteEligibleInvoked = true + siteEligibilityCheckCount += 1 return isSiteEligible } @@ -23,3 +26,10 @@ final class MockBlazeEligibilityChecker: BlazeEligibilityCheckerProtocol { isProductEligible } } + +// MARK: Test helper +extension MockBlazeEligibilityChecker { + func updateSiteEligibility(_ isEligible: Bool) { + isSiteEligible = isEligible + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockCardReaderSettingsAlerts.swift b/WooCommerce/WooCommerceTests/Mocks/MockCardReaderSettingsAlerts.swift index 02bb83ca820..74f2321921b 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockCardReaderSettingsAlerts.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockCardReaderSettingsAlerts.swift @@ -17,6 +17,9 @@ final class MockCardReaderSettingsAlerts { private var mode: MockCardReaderSettingsAlertsMode private var didPresentFoundReader: Bool + var onLocationRequestPreAlert: ((_ onLocationRequestPreAlert: @escaping () -> Void) -> Void)? + var onLocationRequired: ((_ dismiss: @escaping () -> Void, _ skip: @escaping () -> Void) -> Void)? + init(mode: MockCardReaderSettingsAlertsMode) { self.mode = mode self.didPresentFoundReader = false @@ -164,6 +167,20 @@ extension MockCardReaderSettingsAlerts: BluetoothReaderConnnectionAlertsProvidin func selectSearchType(tapToPay: @escaping () -> Void, bluetooth: @escaping () -> Void, cancel: @escaping () -> Void) -> CardPresentPaymentsModalViewModel { return MockCardPresentPaymentsModalViewModel() } + + func locationRequestPreAlert(requestPermission: @escaping () -> Void) -> any AlertDetails { + if let onLocationRequestPreAlert { + onLocationRequestPreAlert(requestPermission) + } + return MockCardPresentPaymentsModalViewModel() + } + + func locationRequired(dismiss: @escaping () -> Void, skip: @escaping () -> Void) -> any AlertDetails { + if let onLocationRequired { + onLocationRequired(dismiss, skip) + } + return MockCardPresentPaymentsModalViewModel() + } } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index 86c0fe0e997..064aa9e94bf 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -1,30 +1,32 @@ @testable import WooCommerce import Experiments -struct MockFeatureFlagService: FeatureFlagService { - private let isInboxOn: Bool - private let isShowInboxCTAEnabled: Bool - private let isUpdateOrderOptimisticallyOn: Bool - private let shippingLabelsOnboardingM1: Bool - private let isDomainSettingsEnabled: Bool - private let isSupportRequestEnabled: Bool - private let jetpackSetupWithApplicationPassword: Bool - private let betterCustomerSelectionInOrder: Bool - private let productBundlesInOrderForm: Bool - private let isScanToUpdateInventoryEnabled: Bool - private let isBackendReceiptsEnabled: Bool - private let sideBySideViewForOrderForm: Bool - private let isSubscriptionsInOrderCreationCustomersEnabled: Bool - private let isPointOfSaleEnabled: Bool - private let googleAdsCampaignCreationOnWebView: Bool - private let blazeEvergreenCampaigns: Bool - private let blazeCampaignObjective: Bool - private let revampedShippingLabelCreation: Bool - private let viewEditCustomFieldsInProductsAndOrders: Bool - private let favoriteProducts: Bool - private let paymentsOnboardingInPointOfSale: Bool - private let isProductGlobalUniqueIdentifierSupported: Bool - private let isSendReceiptAfterPaymentEnabled: Bool +final class MockFeatureFlagService: FeatureFlagService { + var isInboxOn: Bool + var isShowInboxCTAEnabled: Bool + var isUpdateOrderOptimisticallyOn: Bool + var shippingLabelsOnboardingM1: Bool + var isDomainSettingsEnabled: Bool + var isSupportRequestEnabled: Bool + var jetpackSetupWithApplicationPassword: Bool + var betterCustomerSelectionInOrder: Bool + var productBundlesInOrderForm: Bool + var isScanToUpdateInventoryEnabled: Bool + var isBackendReceiptsEnabled: Bool + var sideBySideViewForOrderForm: Bool + var isSubscriptionsInOrderCreationCustomersEnabled: Bool + var isPointOfSaleEnabled: Bool + var googleAdsCampaignCreationOnWebView: Bool + var blazeEvergreenCampaigns: Bool + var blazeCampaignObjective: Bool + var revampedShippingLabelCreation: Bool + var viewEditCustomFieldsInProductsAndOrders: Bool + var favoriteProducts: Bool + var isProductGlobalUniqueIdentifierSupported: Bool + var isSendReceiptAfterPaymentEnabled: Bool + var tapToPayEducation: Bool + var receiptsForPOS: Bool + var hideSitesInStorePicker: Bool init(isInboxOn: Bool = false, isShowInboxCTAEnabled: Bool = false, @@ -46,9 +48,11 @@ struct MockFeatureFlagService: FeatureFlagService { revampedShippingLabelCreation: Bool = false, viewEditCustomFieldsInProductsAndOrders: Bool = false, favoriteProducts: Bool = false, - paymentsOnboardingInPointOfSale: Bool = false, isProductGlobalUniqueIdentifierSupported: Bool = false, - isSendReceiptAfterPaymentEnabled: Bool = false) { + isSendReceiptAfterPaymentEnabled: Bool = false, + tapToPayEducation: Bool = false, + receiptsForPOS: Bool = false, + hideSitesInStorePicker: Bool = false) { self.isInboxOn = isInboxOn self.isShowInboxCTAEnabled = isShowInboxCTAEnabled self.isUpdateOrderOptimisticallyOn = isUpdateOrderOptimisticallyOn @@ -69,9 +73,11 @@ struct MockFeatureFlagService: FeatureFlagService { self.revampedShippingLabelCreation = revampedShippingLabelCreation self.viewEditCustomFieldsInProductsAndOrders = viewEditCustomFieldsInProductsAndOrders self.favoriteProducts = favoriteProducts - self.paymentsOnboardingInPointOfSale = paymentsOnboardingInPointOfSale self.isProductGlobalUniqueIdentifierSupported = isProductGlobalUniqueIdentifierSupported self.isSendReceiptAfterPaymentEnabled = isSendReceiptAfterPaymentEnabled + self.tapToPayEducation = tapToPayEducation + self.receiptsForPOS = receiptsForPOS + self.hideSitesInStorePicker = hideSitesInStorePicker } func isFeatureFlagEnabled(_ featureFlag: FeatureFlag) -> Bool { @@ -116,12 +122,16 @@ struct MockFeatureFlagService: FeatureFlagService { return viewEditCustomFieldsInProductsAndOrders case .favoriteProducts: return favoriteProducts - case .paymentsOnboardingInPointOfSale: - return paymentsOnboardingInPointOfSale case .productGlobalUniqueIdentifierSupport: return isProductGlobalUniqueIdentifierSupported case .sendReceiptAfterPayment: return isSendReceiptAfterPaymentEnabled + case .tapToPayEducation: + return tapToPayEducation + case .sendReceiptsForPointOfSale: + return receiptsForPOS + case .hideSitesInStorePicker: + return hideSitesInStorePicker default: return false } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockImageService.swift b/WooCommerce/WooCommerceTests/Mocks/MockImageService.swift index 5154e472875..f3680abe639 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockImageService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockImageService.swift @@ -43,4 +43,8 @@ extension MockImageService: ImageService { completion: ImageDownloadCompletion?) { // no-op } + + func clearMemoryCache() { + // no-op + } } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockLocationService.swift b/WooCommerce/WooCommerceTests/Mocks/MockLocationService.swift new file mode 100644 index 00000000000..21911de4b69 --- /dev/null +++ b/WooCommerce/WooCommerceTests/Mocks/MockLocationService.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import WooCommerce + +final class MockLocationService: LocationServiceProtocol { + private var observers: [(LocationAuthorizationStatus) -> Void] = [] + private var currentStatus: LocationAuthorizationStatus + var requestPermissionStatus: LocationAuthorizationStatus = .notDetermined + + init(status: LocationAuthorizationStatus = .authorized) { + self.currentStatus = status + } + + var authorizationStatus: LocationAuthorizationStatus { + currentStatus + } + + func requestPermission() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.currentStatus = self.requestPermissionStatus + self.notifyObservers() + } + } + + func observePermissionChanges(_ onChange: @escaping (LocationAuthorizationStatus) -> Void) { + observers.append(onChange) + } + + func stopObservingPermissionChanges() { + observers.removeAll() + } + + func simulatePermissionChange(to newStatus: LocationAuthorizationStatus) { + currentStatus = newStatus + notifyObservers() + } + + private func notifyObservers() { + for observer in observers { + observer(currentStatus) + } + } +} diff --git a/WooCommerce/WooCommerceTests/Mocks/MockPushNotificationsManager.swift b/WooCommerce/WooCommerceTests/Mocks/MockPushNotificationsManager.swift index 5799b04fde9..d60eda8fec3 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockPushNotificationsManager.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockPushNotificationsManager.swift @@ -5,6 +5,7 @@ import UIKit import Yosemite final class MockPushNotificationsManager: PushNotesManager { + func disableInAppNotifications() { } @@ -41,6 +42,12 @@ final class MockPushNotificationsManager: PushNotesManager { localNotificationResponsesSubject.eraseToAnyPublisher() } + private let mockedDeviceID: String? + + var deviceID: String? { + mockedDeviceID + } + private let localNotificationResponsesSubject = PassthroughSubject() private(set) var requestedLocalNotifications: [LocalNotification] = [] @@ -50,6 +57,10 @@ final class MockPushNotificationsManager: PushNotesManager { private(set) var canceledLocalNotificationScenarios: [[LocalNotification.Scenario]] = [] private(set) var resetBadgeCountKinds: [Note.Kind] = [] + init(mockedDeviceID: String? = nil) { + self.mockedDeviceID = mockedDeviceID + } + func resetBadgeCount(type: Note.Kind) { resetBadgeCountKinds.append(type) } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockReceiptEligibilityUseCase.swift b/WooCommerce/WooCommerceTests/Mocks/MockReceiptEligibilityUseCase.swift index 5d16fcf9f8b..054eb3e6c28 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockReceiptEligibilityUseCase.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockReceiptEligibilityUseCase.swift @@ -2,13 +2,18 @@ final class MockReceiptEligibilityUseCase: ReceiptEligibilityUseCaseProtocol { var isEligibleForBackendReceipts: Bool = true - var isEligibleSendingReceiptAfterPayment: Bool = false + var isEligibleForSuccessfulPaymentEmailReceipts: Bool = false + var isEligibleForFailedPaymentEmailReceipts: Bool = false func isEligibleForBackendReceipts(onCompletion: @escaping (Bool) -> Void) { onCompletion(isEligibleForBackendReceipts) } - func isEligibleSendingReceiptAfterPayment(onCompletion: @escaping (Bool) -> Void) { - onCompletion(isEligibleSendingReceiptAfterPayment) + func isEligibleForSuccessfulPaymentEmailReceipts(onCompletion: @escaping (Bool) -> Void) { + onCompletion(isEligibleForSuccessfulPaymentEmailReceipts) + } + + func isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: String, onCompletion: @escaping (Bool) -> Void) { + onCompletion(isEligibleForFailedPaymentEmailReceipts) } } diff --git a/WooCommerce/WooCommerceTests/Model/MarkOrderAsReadUseCaseTests.swift b/WooCommerce/WooCommerceTests/Model/MarkOrderAsReadUseCaseTests.swift index eac7983300a..383dcf00d46 100644 --- a/WooCommerce/WooCommerceTests/Model/MarkOrderAsReadUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/Model/MarkOrderAsReadUseCaseTests.swift @@ -28,13 +28,6 @@ final class MarkOrderAsReadUseCaseTests: XCTestCase { storageManager = MockStorageManager() storesManager = MockStoresManager(sessionManager: .makeForTesting()) network = MockNetwork() - - NotificationStore.resetSharedDerivedStorage() - } - - override func tearDown() { - NotificationStore.resetSharedDerivedStorage() - super.tearDown() } private func setupStoreManagerReceivingNotificationActions(for note: Yosemite.Note, noteStore: NotificationStore) { diff --git a/WooCommerce/WooCommerceTests/Notifications/OrderNotificationViewModelTests.swift b/WooCommerce/WooCommerceTests/Notifications/OrderNotificationViewModelTests.swift index 6bbb87b1a02..ed0329ff233 100644 --- a/WooCommerce/WooCommerceTests/Notifications/OrderNotificationViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/Notifications/OrderNotificationViewModelTests.swift @@ -33,7 +33,6 @@ final class OrderNotificationViewModelTests: XCTestCase { extension OrderNotificationViewModelTests { func sampleNote() -> Note { - let storeTitle = "My Test Store" let range = NoteRange.fake().copy(range: .init(location: 23, length: 13)) let block = NoteBlock.fake().copy(ranges: [range], text: "You have a new Order - My Test Store") return Note.fake().copy(subject: [block]) diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift index f6f14342cc7..10304cebf38 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleItemsControllerTests.swift @@ -5,17 +5,19 @@ import Combine final class PointOfSaleItemsControllerTests { private let itemProvider: MockPointOfSaleItemService private let sut: PointOfSaleItemsController - @Published var itemListState: ItemListState = .initialLoading + @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, + itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])) init() { itemProvider = MockPointOfSaleItemService() sut = PointOfSaleItemsController(itemProvider: itemProvider) - sut.itemListStatePublisher.assign(to: &$itemListState) + sut.itemsViewStatePublisher.assign(to: &$itemsViewState) } @Test func loadInitialItems_requests_first_page() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadInitialItems() @@ -27,18 +29,20 @@ final class PointOfSaleItemsControllerTests { @Test func loadInitialItems_results_in_loaded_state() async throws { // Given let expectedItems = MockPointOfSaleItemService.makeInitialItems() - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadInitialItems() // Then - #expect(itemListState == .loaded(expectedItems)) + #expect(itemsViewState == ItemsViewState(containerState: .content, + itemsStack: ItemsStackState(root: .loaded(expectedItems), + itemStates: [:]))) } @Test func loadInitialItems_when_called_multiple_times_then_items_are_not_duplicated() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) let expectedItems = MockPointOfSaleItemService.makeInitialItems() // When @@ -47,8 +51,8 @@ final class PointOfSaleItemsControllerTests { await sut.loadInitialItems() // Then - guard case .loaded(let items) = itemListState else { - Issue.record("Expected loaded ItemList state, but got \(itemListState)") + guard case .loaded(let items) = itemsViewState.itemsStack.root else { + Issue.record("Expected loaded ItemList state, but got \(itemsViewState)") return } #expect(items.count == expectedItems.count) @@ -56,15 +60,15 @@ final class PointOfSaleItemsControllerTests { @Test func reload_results_in_loaded_state() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) let expectedItems = MockPointOfSaleItemService.makeInitialItems() // When await sut.reload() // Then - guard case .loaded(let items) = itemListState else { - Issue.record("Expected loaded ItemList state, but got \(itemListState)") + guard case .loaded(let items) = itemsViewState.itemsStack.root else { + Issue.record("Expected loaded ItemList state, but got \(itemsViewState)") return } #expect(items.count == expectedItems.count) @@ -72,7 +76,7 @@ final class PointOfSaleItemsControllerTests { @Test func reload_when_called_multiple_times_then_items_are_not_duplicated() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) let expectedItems = MockPointOfSaleItemService.makeInitialItems() // When @@ -81,29 +85,29 @@ final class PointOfSaleItemsControllerTests { await sut.reload() // Then - guard case .loaded(let items) = itemListState else { - Issue.record("Expected loaded ItemList state, but got \(itemListState)") + guard case .loaded(let items) = itemsViewState.itemsStack.root else { + Issue.record("Expected loaded ItemList state, but got \(itemsViewState)") return } #expect(items.count == expectedItems.count) } - @Test func state_starts_as_initialLoading() { + @Test func container_state_starts_as_loading() { // Given/When/Then - #expect(itemListState == .initialLoading) + #expect(itemsViewState.containerState == .loading) } - @Test func loadItems_when_initial_items_empty_then_state_is_empty() async throws { + @Test func loadItems_when_initial_items_empty_then_container_state_is_empty() async throws { // Given itemProvider.shouldReturnZeroItems = true - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadNextItems() // Then - #expect(itemListState == .empty) + #expect(itemsViewState.containerState == .empty) } @Test func loadItems_when_initial_items_has_items_then_state_is_loaded_with_initial_items() async throws { @@ -111,13 +115,15 @@ final class PointOfSaleItemsControllerTests { let initialItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.items = initialItems - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadNextItems() // Then - #expect(itemListState == .loaded(initialItems)) + #expect(itemsViewState == ItemsViewState(containerState: .content, + itemsStack: ItemsStackState(root: .loaded(initialItems), + itemStates: [:]))) } @Test func loadItems_when_simulateFetchNextPage_then_state_is_loaded_with_expected_items() async throws { @@ -125,13 +131,14 @@ final class PointOfSaleItemsControllerTests { let initialItems = MockPointOfSaleItemService.makeInitialItems() itemProvider.items = initialItems itemProvider.shouldSimulateTwoPages = true + await sut.loadInitialItems() // When await sut.loadNextItems() // Then - guard case .loaded(let items) = itemListState else { - Issue.record("Expected loaded ItemList state, but got \(itemListState)") + guard case .loaded(let items) = itemsViewState.itemsStack.root else { + Issue.record("Expected loaded ItemList state, but got \(itemsViewState)") return } #expect(items.count == 4) @@ -139,7 +146,9 @@ final class PointOfSaleItemsControllerTests { @Test func loadNextItems_requests_second_page() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) + itemProvider.shouldSimulateTwoPages = true + await sut.loadInitialItems() // When await sut.loadNextItems() @@ -152,13 +161,13 @@ final class PointOfSaleItemsControllerTests { // Given itemProvider.shouldReturnZeroItems = true - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadInitialItems() // Then - #expect(itemListState == .empty) + #expect(itemsViewState.containerState == .empty) } @Test func loadInitialItems_when_itemProvider_throws_error_then_state_is_error() async throws { @@ -167,32 +176,39 @@ final class PointOfSaleItemsControllerTests { let expectedError = PointOfSaleErrorState(title: "Error loading products", subtitle: "Give it another go?", buttonText: "Retry") - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.loadInitialItems() // Then - #expect(itemListState == .error(expectedError)) + #expect(itemsViewState.containerState == .error(expectedError)) } @Test func loadNextItems_when_itemProvider_throws_error_then_state_is_error() async throws { // Given + try #require(itemsViewState.containerState == .loading) + + itemProvider.shouldSimulateTwoPages = true + await sut.loadInitialItems() + itemProvider.shouldThrowError = true let expectedError = PointOfSaleErrorState(title: "Error loading products", subtitle: "Give it another go?", buttonText: "Retry") - try #require(itemListState == .initialLoading) // When await sut.loadNextItems() // Then - #expect(itemListState == .error(expectedError)) + #expect(itemsViewState.containerState == .error(expectedError)) } @Test func loadNextItems_after_itemProvider_throws_error_then_the_same_page_is_requested_next() async throws { // Given + itemProvider.shouldSimulateTwoPages = true + await sut.loadInitialItems() + itemProvider.shouldThrowError = true await sut.loadNextItems() try #require(itemProvider.spyLastRequestedPageNumber == 2) @@ -207,18 +223,23 @@ final class PointOfSaleItemsControllerTests { @Test func reload_results_in_state_loaded_with_expected_items() async throws { // Given - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) let expectedItems = MockPointOfSaleItemService.makeInitialItems() // When await sut.reload() // Then - #expect(itemListState == .loaded(expectedItems)) + #expect(itemsViewState == ItemsViewState(containerState: .content, + itemsStack: ItemsStackState(root: .loaded(expectedItems), + itemStates: [:]))) } @Test func reload_requests_first_page() async throws { // Given + itemProvider.shouldSimulateTwoPages = true + await sut.loadInitialItems() + await sut.loadNextItems() try #require(itemProvider.spyLastRequestedPageNumber == 2) @@ -229,33 +250,28 @@ final class PointOfSaleItemsControllerTests { #expect(itemProvider.spyLastRequestedPageNumber == 1) } - @Test func loadNextItems_when_next_page_is_out_of_range_then_receives_error() async throws { + @Test func loadNextItems_when_next_page_is_empty_then_state_is_loaded() async throws { // Given await sut.loadInitialItems() try #require(itemProvider.spyLastRequestedPageNumber == 1) - let expectedError = PointOfSaleErrorState(title: "Error loading products", - subtitle: "Give it another go?", - buttonText: "Retry") // When - itemProvider.simulateNextPageIsOutOfRange() + itemProvider.shouldReturnZeroItems = true await sut.loadNextItems() // Then - guard case .error = itemListState else { - Issue.record("Expected error state, but got \(itemListState)") - return - } - #expect(itemListState == .error(expectedError)) + #expect(itemsViewState == ItemsViewState(containerState: .content, + itemsStack: ItemsStackState(root: .loaded(MockPointOfSaleItemService.makeInitialItems()), + itemStates: [:]))) } - @Test func loadNextItems_when_next_page_is_out_of_range_then_the_same_page_is_requested_next() async throws { + @Test func loadNextItems_when_next_page_is_empty_then_the_same_page_is_requested_next() async throws { // Given await sut.loadInitialItems() try #require(itemProvider.spyLastRequestedPageNumber == 1) // When - itemProvider.simulateNextPageIsOutOfRange() + itemProvider.shouldReturnZeroItems = true await sut.loadNextItems() // Then @@ -269,12 +285,12 @@ final class PointOfSaleItemsControllerTests { subtitle: "Give it another go?", buttonText: "Retry") - try #require(itemListState == .initialLoading) + try #require(itemsViewState.containerState == .loading) // When await sut.reload() // Then - #expect(itemListState == .error(expectedError)) + #expect(itemsViewState.containerState == .error(expectedError)) } } diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index e452845e466..2ca1c87da09 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -9,9 +9,10 @@ import struct Yosemite.OrderItem struct PointOfSaleOrderControllerTests { let sut: PointOfSaleOrderController let mockOrderService = MockPOSOrderService() + let mockReceiptService = MockReceiptService() init() { - self.sut = PointOfSaleOrderController(orderService: mockOrderService) + self.sut = PointOfSaleOrderController(orderService: mockOrderService, receiptService: mockReceiptService) } @Test func syncOrder_without_items_doesnt_call_orderService() async throws { @@ -142,6 +143,46 @@ struct PointOfSaleOrderControllerTests { handler: {})) ]) } + + @Test func sendReceipt_when_there_is_no_order_then_will_not_trigger() async throws { + // Given + let email = "test@example.com" + + // When + try await sut.sendReceipt(recipientEmail: email) + + // Then + #expect(!mockOrderService.updateOrderWasCalled) + #expect(!mockReceiptService.sendReceiptWasCalled) + } + + @Test func sendReceipt_calls_both_updateOrder_and_sendReceipt() async throws { + // Given + let order = Order.fake() + let recipientEmail = "test@fake.com" + mockOrderService.orderToReturn = order + + // We need an existing order before we can update its email, and send a receipt: + await sut.syncOrder(for: [makeItem()], retryHandler: { }) + + // When + try await sut.sendReceipt(recipientEmail: recipientEmail) + + // Then + #expect(mockOrderService.updateOrderWasCalled) + #expect(mockOrderService.orderToReturn?.billingAddress?.email == recipientEmail) + #expect(mockReceiptService.sendReceiptWasCalled) + } + + @Test func collectCashPayment_when_no_order_then_fails_with_noOrder_error() async throws { + do { + // Given/When + try await sut.collectCashPayment() + } catch let error as PointOfSaleOrderController.PointOfSaleOrderControllerError { + // Then + #expect(error == .noOrder) + } + } } private func makeItem(name: String = "", @@ -149,8 +190,8 @@ private func makeItem(name: String = "", quantity: Int = 1, orderItemsToMatch: [OrderItem] = []) -> CartItem { return CartItem(id: UUID(), - item: MockPOSItem(name: name, - formattedPrice: formattedPrice, - orderItemsToMatch: orderItemsToMatch), + item: MockPOSOrderableItem(name: name, + formattedPrice: formattedPrice, + orderItemsToMatch: orderItemsToMatch), quantity: quantity) } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSItemProvider.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSItemProvider.swift index 8c023f53ca2..273e483a565 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSItemProvider.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSItemProvider.swift @@ -1,79 +1,76 @@ import Foundation import protocol Yosemite.PointOfSaleItemServiceProtocol -import protocol Yosemite.POSDisplayableItem -@testable import struct Yosemite.POSProduct +import enum Yosemite.POSItem +import protocol Yosemite.POSOrderableItem +@testable import struct Yosemite.POSSimpleProduct +import struct Yosemite.PagedItems +import struct Yosemite.POSVariableParentProduct final class MockPointOfSaleItemService: PointOfSaleItemServiceProtocol { - var items: [POSDisplayableItem] = [] + var items: [POSItem] = [] var shouldThrowError = false var shouldReturnZeroItems = false var shouldSimulateTwoPages = false - private var isPageOutOfRange = false var spyLastRequestedPageNumber: Int? - func providePointOfSaleItems(pageNumber: Int) async throws -> [POSDisplayableItem] { - if isPageOutOfRange { - throw MockError.pageOutOfRange - } + func providePointOfSaleItems(pageNumber: Int) async throws -> PagedItems { spyLastRequestedPageNumber = pageNumber if shouldThrowError { throw MockError.requestFailed } if shouldReturnZeroItems { - return [] + return .init(items: [], hasMorePages: false) } if shouldSimulateTwoPages, pageNumber > 1 { - simulateFetchNextPage() - return items + return .init(items: MockPointOfSaleItemService.makeSecondPageItems(), hasMorePages: false) } - return MockPointOfSaleItemService.makeInitialItems() - } - - func simulateFetchNextPage() { - items.append(contentsOf: MockPointOfSaleItemService.makeSecondPageItems()) + return .init(items: MockPointOfSaleItemService.makeInitialItems(), hasMorePages: shouldSimulateTwoPages) } - func simulateNextPageIsOutOfRange() { - isPageOutOfRange = true + func providePointOfSaleVariationItems(for parentProduct: POSVariableParentProduct, pageNumber: Int) async throws -> PagedItems { + .init(items: [], hasMorePages: false) } } extension MockPointOfSaleItemService { - static func makeInitialItems() -> [POSDisplayableItem] { + static func makeInitialItems() -> [POSItem] { let fakeUUID1 = UUID(uuidString: "DC55E3B9-9D83-4C07-82A7-4C300A50E84E") ?? UUID() let fakeUUID2 = UUID(uuidString: "DC55E3B8-9D82-4C06-82A5-4C300A50E84A") ?? UUID() - let product1 = MockPOSItem(name: "Choco", - id: fakeUUID1, - formattedPrice: "$2.00", - productImageSource: nil) + let product1 = POSSimpleProduct(id: fakeUUID1, + name: "Choco", + formattedPrice: "$2.00", + productID: 1, + price: "2.00") - let product2 = MockPOSItem(name: "Vanilla", - id: fakeUUID2, - formattedPrice: "$3.00", - productImageSource: nil) - return [product1, product2] + let product2 = POSSimpleProduct(id: fakeUUID2, + name: "Vanilla", + formattedPrice: "$3.00", + productID: 1, + price: "2.00") + return [.simpleProduct(product1), .simpleProduct(product2)] } - static func makeSecondPageItems() -> [POSDisplayableItem] { + static func makeSecondPageItems() -> [POSItem] { let fakeUUID3 = UUID(uuidString: "DC55E3B9-9D83-4C07-82A7-4C300A50E86D") ?? UUID() let fakeUUID4 = UUID(uuidString: "DC55E3B8-9D82-4C06-82A5-4C300A50E86F") ?? UUID() - let product3 = MockPOSItem(name: "Strawberry", - id: fakeUUID3, - formattedPrice: "$2.00", - productImageSource: nil) + let product3 = POSSimpleProduct(id: fakeUUID3, + name: "Strawberry", + formattedPrice: "$2.00", + productID: 1, + price: "2.00") - let product4 = MockPOSItem(name: "Pistachio", - id: fakeUUID4, - formattedPrice: "$3.00", - productImageSource: nil) - return [product3, product4] + let product4 = POSSimpleProduct(id: fakeUUID4, + name: "Pistachio", + formattedPrice: "$3.00", + productID: 1, + price: "2.00") + return [.simpleProduct(product3), .simpleProduct(product4)] } enum MockError: Error { case requestFailed - case pageOutOfRange } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift index 86cb00cc875..ea0ec467fe2 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPOSOrderService.swift @@ -6,6 +6,8 @@ class MockPOSOrderService: POSOrderServiceProtocol { var orderToReturn: Order? var syncOrderWasCalled = false + var updateOrderWasCalled = false + func syncOrder(cart: [Yosemite.POSCartItem], order: Yosemite.Order?) async throws -> Yosemite.Order { syncOrderWasCalled = true @@ -19,7 +21,22 @@ class MockPOSOrderService: POSOrderServiceProtocol { return order } - func sendReceipt(order: Yosemite.Order, recipientEmail: String) async throws { } + func updatePOSOrder(order: Order, recipientEmail: String) async throws { + updateOrderWasCalled = true + + let orderWithUpdatedEmail = MockOrders().sampleOrder().copy(billingAddress: .init(firstName: "", + lastName: "", + company: nil, + address1: "", + address2: nil, + city: "", + state: "", + postcode: "", + country: "", + phone: nil, + email: recipientEmail)) + orderToReturn = orderWithUpdatedEmail + } } enum MockPOSOrderServiceError: Error { diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift index 3c14290df86..013395dc587 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift @@ -1,5 +1,6 @@ import Foundation @testable import WooCommerce +import enum Yosemite.POSItem import protocol Yosemite.POSOrderableItem final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { @@ -25,16 +26,17 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { var orderState: WooCommerce.PointOfSaleOrderState - var itemListState: ItemListState + var itemsViewState: ItemsViewState var blockReturnToItemSelection: Bool = false init(cardReaderConnectionStatus: CardPresentPaymentReaderConnectionStatus = .disconnected, - itemListState: ItemListState = .initialLoading, + itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading, itemsStack: ItemsStackState(root: .loading([]), + itemStates: [:])), orderStage: PointOfSaleOrderStage = .building, orderState: PointOfSaleOrderState = .idle, paymentState: PointOfSalePaymentState = .idle) { self.cardReaderConnectionStatus = cardReaderConnectionStatus - self.itemListState = itemListState + self.itemsViewState = itemsViewState self.orderStage = orderStage self.orderState = orderState self.paymentState = paymentState @@ -46,6 +48,8 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { func reload() async { } + func loadInitialChildItems(for parent: POSItem) async { } + var cart: [CartItem] = [] func addToCart(_ item: POSOrderableItem) { } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleItemsService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleItemsService.swift index c244c6bd966..189b64e9b6e 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleItemsService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleItemsService.swift @@ -1,16 +1,16 @@ import Foundation import Combine @testable import WooCommerce -import protocol Yosemite.POSDisplayableItem +import enum Yosemite.POSItem final class MockPointOfSaleItemsController: PointOfSaleItemsControllerProtocol { - var itemListStatePublisher: any Publisher = Empty() - - var allItems: [POSDisplayableItem] = [] + var itemsViewStatePublisher: any Publisher = Empty() func loadInitialItems() async { } func loadNextItems() async { } func reload() async { } + + func loadInitialChildItems(for parent: Yosemite.POSItem) async { } } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index 7a0c0b3291d..8b894e3285e 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -5,6 +5,10 @@ import Combine import struct Yosemite.Order final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { + func collectCashPayment() async throws { + // no-op + } + var orderStatePublisher: AnyPublisher { $orderState.eraseToAnyPublisher() } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift new file mode 100644 index 00000000000..36b0abe8995 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockReceiptService.swift @@ -0,0 +1,10 @@ +import Foundation +@testable import Yosemite + +class MockReceiptService: POSReceiptServiceProtocol { + var sendReceiptWasCalled = false + + func sendReceipt(order: Yosemite.Order, recipientEmail: String) async throws { + sendReceiptWasCalled = true + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift index 353f3ae052f..86c0437e7f9 100644 --- a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift @@ -2,7 +2,7 @@ import Testing import Foundation @testable import WooCommerce import protocol Yosemite.POSOrderableItem -@testable import struct Yosemite.POSProduct +@testable import struct Yosemite.POSSimpleProduct import struct Yosemite.Order import Combine @@ -480,7 +480,7 @@ struct PointOfSaleAggregateModelTests { } private func makeItem(name: String = "") -> POSOrderableItem { - return MockPOSItem(name: name, formattedPrice: "") + return MockPOSOrderableItem(name: name, formattedPrice: "") } private func makeLoadedOrderState(cartTotal: String = "", diff --git a/WooCommerce/WooCommerceTests/POS/ViewModels/CartViewHelperTests.swift b/WooCommerce/WooCommerceTests/POS/ViewModels/CartViewHelperTests.swift index eff14551f99..8bec8feabb3 100644 --- a/WooCommerce/WooCommerceTests/POS/ViewModels/CartViewHelperTests.swift +++ b/WooCommerce/WooCommerceTests/POS/ViewModels/CartViewHelperTests.swift @@ -84,5 +84,5 @@ struct CartViewHelperTests { } private func makeItem() -> CartItem { - CartItem(id: UUID(), item: MockPOSItem(name: "Item", formattedPrice: "$1.00"), quantity: 1) + CartItem(id: UUID(), item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"), quantity: 1) } diff --git a/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift b/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift index 9830d9b53c4..104f0cf0c0a 100644 --- a/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift +++ b/WooCommerce/WooCommerceTests/System/SessionManagerTests.swift @@ -340,48 +340,25 @@ final class SessionManagerTests: XCTestCase { XCTAssertNil(defaults[.themesPendingInstall]) } - /// Verifies that `siteIDPendingStoreSwitch` is set to `nil` upon reset + /// Verifies that `hiddenStoreIDs` is set to `nil` upon reset /// - func test_siteIDPendingStoreSwitch_is_set_to_nil_upon_reset() throws { + func test_hiddenStoreIDs_is_set_to_nil_upon_reset() throws { // Given let uuid = UUID().uuidString let defaults = try XCTUnwrap(UserDefaults(suiteName: uuid)) let sut = SessionManager(defaults: defaults, keychainServiceName: Settings.keychainServiceName) - let siteID: Int64 = 123 - - // When - defaults[.siteIDPendingStoreSwitch] = siteID - - // Then - XCTAssertEqual(try XCTUnwrap(defaults[.siteIDPendingStoreSwitch] as? Int64), siteID) - - // When - sut.reset() - - // Then - XCTAssertNil(defaults[.siteIDPendingStoreSwitch]) - } - - /// Verifies that `expectedStoreNamePendingStoreSwitch` is set to `nil` upon reset - /// - func test_expectedStoreNamePendingStoreSwitch_is_set_to_nil_upon_reset() throws { - // Given - let uuid = UUID().uuidString - let defaults = try XCTUnwrap(UserDefaults(suiteName: uuid)) - let sut = SessionManager(defaults: defaults, keychainServiceName: Settings.keychainServiceName) - let storeName = "My Woo Store" // When - defaults[.expectedStoreNamePendingStoreSwitch] = storeName + defaults[.hiddenStoreIDs] = [Int64]([123, 666]) // Then - XCTAssertEqual(try XCTUnwrap(defaults[.expectedStoreNamePendingStoreSwitch] as? String), storeName) + XCTAssertEqual(try XCTUnwrap(defaults[.hiddenStoreIDs] as? [Int64]), [123, 666]) // When sut.reset() // Then - XCTAssertNil(defaults[.expectedStoreNamePendingStoreSwitch]) + XCTAssertNil(defaults[.hiddenStoreIDs]) } /// Verifies that `blazeNoCampaignReminderOpened` is set to `nil` upon reset diff --git a/WooCommerce/WooCommerceTests/Tools/CurrencySettingsTests.swift b/WooCommerce/WooCommerceTests/Tools/CurrencySettingsTests.swift index b4ea5c6bac7..0b0b7e9bbc4 100644 --- a/WooCommerce/WooCommerceTests/Tools/CurrencySettingsTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/CurrencySettingsTests.swift @@ -153,9 +153,25 @@ final class CurrencySettingsTests: XCTestCase { /// Test currency symbol lookup returns correctly encoded symbol. /// - func testCurrencySymbol() { + func testCurrencySymbol_passing_code() { moneyFormat = CurrencySettings() let symbol = moneyFormat?.symbol(from: CurrencyCode.AED) XCTAssertEqual("د.إ", symbol) } + + func test_currencySymbol_default() { + moneyFormat = CurrencySettings() + let symbol = moneyFormat?.currencySymbol + XCTAssertEqual("$", symbol) + } + + func test_currencySymbol_euros() { + moneyFormat = CurrencySettings(currencyCode: .EUR, + currencyPosition: .left, + thousandSeparator: "", + decimalSeparator: "", + numberOfDecimals: 2) + let symbol = moneyFormat?.currencySymbol + XCTAssertEqual("€", symbol) + } } diff --git a/WooCommerce/WooCommerceTests/Tools/LocationServiceTests.swift b/WooCommerce/WooCommerceTests/Tools/LocationServiceTests.swift new file mode 100644 index 00000000000..9eed8fe524f --- /dev/null +++ b/WooCommerce/WooCommerceTests/Tools/LocationServiceTests.swift @@ -0,0 +1,99 @@ +import Testing +import CoreLocation +@testable import WooCommerce + +struct LocationServiceTests { + private let sut: LocationService + private let locationManager: LocationManagerMock + + init() { + locationManager = LocationManagerMock() + sut = LocationService(locationManager: locationManager) + } + + @Test func requestPermission_when_authorizedWhenInUse() { + // Given + locationManager.authorizationStatusToReturn = .authorizedWhenInUse + + // When + sut.requestPermission() + + // Then + #expect(!locationManager.requestWhenInUseAuthorizationCalled) + } + + @Test func requestPermission_when_denied() { + // Given + locationManager.authorizationStatusToReturn = .denied + + // When + sut.requestPermission() + + // Then + #expect(!locationManager.requestWhenInUseAuthorizationCalled) + } + + @Test func requestPermission_when_notDetermined() { + // Given + locationManager.authorizationStatusToReturn = .notDetermined + + // When + sut.requestPermission() + + // Then + #expect(locationManager.requestWhenInUseAuthorizationCalled) + } + + @Test func observePermissionChanges() { + // Given + var status: LocationAuthorizationStatus? + sut.observePermissionChanges { + status = $0 + } + + // When & Then + locationManager.changeAuthorizationStatus(to: .restricted) + #expect(status == .denied) + + locationManager.changeAuthorizationStatus(to: .authorizedAlways) + #expect(status == .authorized) + + locationManager.changeAuthorizationStatus(to: .notDetermined) + #expect(status == .notDetermined) + } + + @Test func stopObservingPermissionChanges() throws { + // Given + var status: LocationAuthorizationStatus? + sut.observePermissionChanges { + status = $0 + } + try #require(sut.authorizationStatus == .notDetermined) + + // When + sut.stopObservingPermissionChanges() + locationManager.changeAuthorizationStatus(to: .authorizedAlways) + + // Then + #expect(status == nil) + #expect(sut.authorizationStatus == .authorized) + } +} + +private class LocationManagerMock: CLLocationManager { + var authorizationStatusToReturn: CLAuthorizationStatus = .notDetermined + var requestWhenInUseAuthorizationCalled: Bool = false + + override var authorizationStatus: CLAuthorizationStatus { + authorizationStatusToReturn + } + + override func requestWhenInUseAuthorization() { + requestWhenInUseAuthorizationCalled = true + } + + func changeAuthorizationStatus(to status: CLAuthorizationStatus) { + authorizationStatusToReturn = status + delegate?.locationManagerDidChangeAuthorization?(self) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewModels/Authentication/AccountCreationFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Authentication/AccountCreationFormViewModelTests.swift index 6f9966dbbd9..fd9997a7d65 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Authentication/AccountCreationFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Authentication/AccountCreationFormViewModelTests.swift @@ -153,7 +153,7 @@ final class AccountCreationFormViewModelTests: XCTestCase { viewModel.password = "simple password" // Then - waitUntil { + await until { self.viewModel.passwordErrorMessage == nil } } @@ -168,7 +168,7 @@ final class AccountCreationFormViewModelTests: XCTestCase { viewModel.email = "real@woocommerce.com" // Then - waitUntil { + await until { self.viewModel.emailErrorMessage == nil } } diff --git a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift index 5e494c8c960..5b0c45e3b70 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/CardPresentPayments/CollectOrderPaymentUseCaseTests.swift @@ -256,7 +256,7 @@ final class CollectOrderPaymentUseCaseTests: XCTestCase { ) mockFailedCardPresentPaymentActions(intent: intent, error: error) - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment = true + receiptEligibilityUseCase.isEligibleForFailedPaymentEmailReceipts = true // When we make a payment thar results in payment processor error waitFor { promise in @@ -290,7 +290,7 @@ final class CollectOrderPaymentUseCaseTests: XCTestCase { let error = CardReaderServiceError.disconnection(underlyingError: .bluetoothDisconnected) mockFailedCardPresentPaymentActions(intent: intent, error: error) - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment = true + receiptEligibilityUseCase.isEligibleForFailedPaymentEmailReceipts = true // When we make a payment that results in card reader disconnection waitFor { promise in @@ -325,7 +325,7 @@ final class CollectOrderPaymentUseCaseTests: XCTestCase { ) mockFailedCardPresentPaymentActions(intent: intent, error: error) - receiptEligibilityUseCase.isEligibleSendingReceiptAfterPayment = true + receiptEligibilityUseCase.isEligibleForFailedPaymentEmailReceipts = true // When we make a payment thar results in card reader disconnection waitFor { promise in diff --git a/WooCommerce/WooCommerceTests/ViewModels/Order Details/Address/LocallyStoredStateNameRetrieverTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Order Details/Address/LocallyStoredStateNameRetrieverTests.swift index 8cf28447de0..13f13986e83 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Order Details/Address/LocallyStoredStateNameRetrieverTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Order Details/Address/LocallyStoredStateNameRetrieverTests.swift @@ -35,10 +35,10 @@ final class LocallyStoredStateNameRetrieverTests: XCTestCase { // Given let stateCode = "TS" let address = Address.fake().copy(state: stateCode, country: "A different country") - let stateOfACountry = StateOfACountry(code: stateCode, name: "Test State") - storageManager.insertSampleCountries(readOnlyCountries: [Country(code: "US", - name: "United States", - states: [StateOfACountry(code: stateCode, name: "Testland")])]) + let stateOfACountry = StateOfACountry(code: stateCode, name: "Testland") + storageManager.insertSampleCountries(readOnlyCountries: [ + Country(code: "US", name: "United States", states: [stateOfACountry]) + ]) // When/Then XCTAssertNil(sut.retrieveLocallyStoredStateName(of: address)) diff --git a/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEligibilityUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEligibilityUseCaseTests.swift index 7afe3922e25..98ec0d4e135 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEligibilityUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEligibilityUseCaseTests.swift @@ -108,6 +108,64 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertFalse(isEligible) } + func test_isEligibleForPOSReceipts_when_WooCommerce_version_is_correct_version_then_returns_true() { + // Given + let featureFlag = MockFeatureFlagService(receiptsForPOS: true) + let stores = MockStoresManager(sessionManager: .makeForTesting()) + let plugin = SystemPlugin.fake().copy(name: "WooCommerce", + version: "9.5", + active: true) + + stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + switch action { + case let .fetchSystemPlugin(_, _, onCompletion): + onCompletion(plugin) + default: + XCTFail("Unexpected action") + } + } + let sut = ReceiptEligibilityUseCase(stores: stores, featureFlagService: featureFlag) + + // When + let isEligible: Bool = waitFor { promise in + sut.isEligibleForPointOfSaleReceipts(onCompletion: { result in + promise(result) + }) + } + + // Then + XCTAssertTrue(isEligible) + } + + func test_isEligibleForPOSReceipts_when_WooCommerce_version_is_incorrect_version_then_returns_false() { + // Given + let featureFlag = MockFeatureFlagService(receiptsForPOS: true) + let stores = MockStoresManager(sessionManager: .makeForTesting()) + let plugin = SystemPlugin.fake().copy(name: "WooCommerce", + version: "9.4", + active: true) + + stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + switch action { + case let .fetchSystemPlugin(_, _, onCompletion): + onCompletion(plugin) + default: + XCTFail("Unexpected action") + } + } + let sut = ReceiptEligibilityUseCase(stores: stores, featureFlagService: featureFlag) + + // When + let isEligible: Bool = waitFor { promise in + sut.isEligibleForPointOfSaleReceipts(onCompletion: { result in + promise(result) + }) + } + + // Then + XCTAssertFalse(isEligible) + } + func test_isEligibleForBackendReceipts_when_WooCommerce_version_is_equal_or_above_minimum_then_returns_true() { // Given let featureFlag = MockFeatureFlagService(isBackendReceiptsEnabled: true) @@ -139,7 +197,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // MARK: - Send Receipt After Payment - func test_isEligibleSendingReceiptAfterPayment_when_feature_flag_is_disabled_then_returns_false() { + func test_isEligibleForFailedPaymentEmailReceipts_when_feature_flag_is_disabled_then_returns_false() { // Given let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: false) let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -147,7 +205,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // When let isEligible: Bool = waitFor { promise in - sut.isEligibleSendingReceiptAfterPayment(onCompletion: { result in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: GatewayID.wcPayments, onCompletion: { result in promise(result) }) } @@ -156,7 +214,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertFalse(isEligible) } - func test_isEligibleSendingReceiptAfterPayment_when_plugins_are_inactive_then_returns_false() { + func test_isEligibleForFailedPaymentEmailReceipts_when_plugins_are_inactive_then_returns_false() { // Given let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: true) let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -180,7 +238,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // When let isEligible: Bool = waitFor { promise in - sut.isEligibleSendingReceiptAfterPayment(onCompletion: { result in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: GatewayID.wcPayments, onCompletion: { result in promise(result) }) } @@ -189,7 +247,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertFalse(isEligible) } - func test_isEligibleSendingReceiptAfterPayment_when_plugins_are_supported_then_returns_true() { + func test_isEligibleForFailedPaymentEmailReceipts_when_plugins_are_supported_then_returns_true() { // Given let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: true) let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -213,7 +271,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // When let isEligible: Bool = waitFor { promise in - sut.isEligibleSendingReceiptAfterPayment(onCompletion: { result in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: GatewayID.wcPayments, onCompletion: { result in promise(result) }) } @@ -222,7 +280,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertTrue(isEligible) } - func test_isEligibleSendingReceiptAfterPayment_when_plugins_are_supported_dev_then_returns_true() { + func test_isEligibleForFailedPaymentEmailReceipts_when_plugins_are_supported_dev_then_returns_true() { // Given let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: true) let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -246,7 +304,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // When let isEligible: Bool = waitFor { promise in - sut.isEligibleSendingReceiptAfterPayment(onCompletion: { result in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: GatewayID.wcPayments, onCompletion: { result in promise(result) }) } @@ -255,7 +313,7 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertTrue(isEligible) } - func test_isEligibleSendingReceiptAfterPayment_when_woopayments_version_is_incorrect_then_returns_false() { + func test_isEligibleForFailedPaymentEmailReceipts_when_woopayments_version_is_incorrect_then_returns_false() { // Given let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: true) let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -279,7 +337,40 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { // When let isEligible: Bool = waitFor { promise in - sut.isEligibleSendingReceiptAfterPayment(onCompletion: { result in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: GatewayID.wcPayments, onCompletion: { result in + promise(result) + }) + } + + // Then + XCTAssertFalse(isEligible) + } + + func test_isEligibleForFailedPaymentEmailReceipts_when_plugins_are_supported_but_stripe_gateway_then_returns_false() { + // Given + let featureFlag = MockFeatureFlagService(isSendReceiptAfterPaymentEnabled: true) + let stores = MockStoresManager(sessionManager: .makeForTesting()) + let wooCommercePlugin = SystemPlugin.fake().copy(name: "WooCommerce", version: "9.5.0", active: true) + let wooPaymentsPlugin = SystemPlugin.fake().copy(name: "WooPayments", version: "8.6.0", active: true) + + stores.whenReceivingAction(ofType: SystemStatusAction.self) { action in + switch action { + case let .fetchSystemPlugin(_, systemPluginName, onCompletion): + if systemPluginName == "WooCommerce" { + onCompletion(wooCommercePlugin) + } else if systemPluginName == "WooPayments" { + onCompletion(wooPaymentsPlugin) + } + default: + XCTFail("Unexpected action") + } + } + + let sut = ReceiptEligibilityUseCase(stores: stores, featureFlagService: featureFlag) + + // When + let isEligible: Bool = waitFor { promise in + sut.isEligibleForFailedPaymentEmailReceipts(paymentGatewayID: "woocommerce-stripe", onCompletion: { result in promise(result) }) } @@ -288,3 +379,9 @@ final class ReceiptEligibilityUseCaseTests: XCTestCase { XCTAssertFalse(isEligible) } } + +private extension ReceiptEligibilityUseCaseTests { + enum GatewayID { + static let wcPayments: String = "woocommerce-payments" + } +} diff --git a/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEmailViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEmailViewModelTests.swift index 541645cf9f9..c431dec6e55 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEmailViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Receipts/ReceiptEmailViewModelTests.swift @@ -1,24 +1,38 @@ import Testing import Foundation import Yosemite +import WooFoundation +import Combine +import WordPressShared @testable import WooCommerce @MainActor struct ReceiptEmailViewModelTests { private let stores: MockStoresManager private let order: Order - private let noticesPresenter: MockNoticePresenter + private let mocks: ReceiptEmailMocks private let sut: ReceiptEmailViewModel + private var subscriptions = Set() init() { stores = MockStoresManager(sessionManager: .testingInstance) order = Order.fake() - noticesPresenter = MockNoticePresenter() + mocks = ReceiptEmailMocks() sut = ReceiptEmailViewModel( order: order, stores: stores, - noticesPresenter: noticesPresenter + emailValidator: mocks.validateEmail, + onResult: mocks.result ) + + // Simulate UI behavior to dismiss the view on success state + sut.$state + .receive(on: DispatchQueue.main) + .filter { $0 == .success } + .sink { [sut] _ in + sut.onDisappear() + } + .store(in: &subscriptions) } @Test func sendReceipt_when_action_succeeds() async { @@ -35,25 +49,25 @@ struct ReceiptEmailViewModelTests { // When let completionResult = await withCheckedContinuation { continuation in - sut.onDismiss = { + mocks.onResult = { continuation.resume(returning: $0) } sut.sendReceipt() } // Then - #expect(completionResult != nil) + #expect(completionResult == .success(order)) } @Test func sendReceipt_when_action_fails() async { // Given send receipt action fails + struct FakeError: Error { + var localizedDescription: String { "Test error" } + } sut.email = "test@test.com" stores.whenReceivingAction(ofType: ReceiptAction.self) { action in switch action { case let .sendReceipt(_, _, onCompletion): - struct FakeError: Error { - var localizedDescription: String { "Test error" } - } onCompletion(.failure(FakeError())) default: #expect(Bool(false), "Unexpected action: \(action)") @@ -62,14 +76,27 @@ struct ReceiptEmailViewModelTests { // When let completionResult = await withCheckedContinuation { continuation in - noticesPresenter.onNoticeQueued = { + mocks.onResult = { continuation.resume(returning: $0) } sut.sendReceipt() } // Then - #expect(completionResult.title != nil) + #expect(completionResult == .failure(FakeError())) + } + + @Test func onDisappear_when_no_action() async { + // When + let completionResult = await withCheckedContinuation { continuation in + mocks.onResult = { + continuation.resume(returning: $0) + } + sut.onDisappear() + } + + // Then + #expect(completionResult == .canceled) } @Test(arguments: [true, false]) @@ -77,7 +104,7 @@ struct ReceiptEmailViewModelTests { // Given sut.email = "test@test.com" var validatedEmail = "" - sut.emailValidator = { email in + mocks.emailValidator = { email in validatedEmail = email return validatorResult } @@ -87,3 +114,35 @@ struct ReceiptEmailViewModelTests { #expect(sut.email == validatedEmail) } } + +// MARK: - Helpers + +private extension ReceiptEmailViewModelTests { + private class ReceiptEmailMocks { + var emailValidator: ((String) -> Bool) = { _ in true } + var onResult: ((ReceiptEmailResult) -> Void) = { _ in } + + func result(_ result: ReceiptEmailResult) { + onResult(result) + } + + func validateEmail(_ email: String) -> Bool { + emailValidator(email) + } + } +} + +extension ReceiptEmailResult { + static func ==(lhs: ReceiptEmailResult, rhs: ReceiptEmailResult) -> Bool { + switch (lhs, rhs) { + case (.success, .success): + return true + case (.failure, .failure): + return true + case (.canceled, .canceled): + return true + default: + return false + } + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeCampaignCreationFormViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeCampaignCreationFormViewModelTests.swift index f9d24f8ab66..1c8038f0e3e 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeCampaignCreationFormViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeCampaignCreationFormViewModelTests.swift @@ -865,6 +865,31 @@ final class BlazeCampaignCreationFormViewModelTests: XCTestCase { let objective = try XCTUnwrap(eventProperties["objective"] as? String) XCTAssertEqual(objective, "sales") } + + @MainActor + func test_suggestion_request_failures_is_tracked() async throws { + // Given + insertProduct(sampleProduct) + mockAISuggestionsFailure(MockError()) + mockDownloadImage(sampleImage) + + let viewModel = BlazeCampaignCreationFormViewModel(siteID: sampleSiteID, + productID: sampleProductID, + stores: stores, + storage: storageManager, + productImageLoader: imageLoader, + analytics: analytics, + onCompletion: {}) + await viewModel.downloadProductImage() + + // When + await viewModel.loadAISuggestions() + + // Then + let index = try XCTUnwrap(analyticsProvider.receivedEvents.firstIndex(of: "blaze_suggestions_loading_failed")) + let eventProperties = try XCTUnwrap(analyticsProvider.receivedProperties[index]) + XCTAssertEqual(eventProperties["error_code"] as? String, "1") + } } private extension BlazeCampaignCreationFormViewModelTests { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeConfirmPaymentViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeConfirmPaymentViewModelTests.swift index 9a6cbd3375a..2559d99c790 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeConfirmPaymentViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeConfirmPaymentViewModelTests.swift @@ -431,7 +431,7 @@ final class BlazeConfirmPaymentViewModelTests: XCTestCase { viewModel.addPaymentWebViewModel?.didAddNewPaymentMethod() // Then - waitUntil { + await until { didTriggerFetchPaymentInfo == true } } @@ -455,7 +455,7 @@ final class BlazeConfirmPaymentViewModelTests: XCTestCase { viewModel.paymentMethodsViewModel?.didSelectPaymentMethod(withID: "payment-method-1") // Then - waitUntil { + await until { viewModel.showAddPaymentSheet == false } } @@ -487,7 +487,7 @@ final class BlazeConfirmPaymentViewModelTests: XCTestCase { viewModel.paymentMethodsViewModel?.didSelectPaymentMethod(withID: "new-payment-method") // Then - waitUntil { + await until { didTriggerFetchPaymentInfo == true } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeLocalNotificationSchedulerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeLocalNotificationSchedulerTests.swift index 357d9cbf0a1..45152b2292b 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeLocalNotificationSchedulerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Blaze/BlazeLocalNotificationSchedulerTests.swift @@ -63,7 +63,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { insertCampaigns([fakeBlazeCampaign]) // Then - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.isNotEmpty } @@ -110,7 +110,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { insertCampaigns([fakeBlazeCampaign1, fakeBlazeCampaign2, fakeBlazeCampaign3]) // Then - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.isNotEmpty } @@ -144,7 +144,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { // When insertCampaigns([fakeBlazeCampaign1]) - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.count == 1 } @@ -180,7 +180,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { insertCampaigns([fakeBlazeCampaign1, fakeBlazeCampaign2]) // Then - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.isNotEmpty } @@ -297,7 +297,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { // When await sut.scheduleNoCampaignReminder() - waitUntil { + await until { self.pushNotesManager.canceledLocalNotificationScenarios.isNotEmpty } @@ -321,7 +321,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { await sut.scheduleAbandonedCreationReminder() // Then - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.isNotEmpty } @@ -367,7 +367,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { // When await sut.scheduleAbandonedCreationReminder() - waitUntil { + await until { self.pushNotesManager.requestedLocalNotifications.count == 1 } @@ -420,7 +420,7 @@ final class BlazeLocalNotificationSchedulerTests: XCTestCase { pushNotesManager.sendLocalNotificationResponse(response) // Then - waitUntil { + await until { self.defaults[.blazeAbandonedCampaignCreationReminderOpened] == true } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/CardPresentPayments/CardReaderConnectionControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/CardPresentPayments/CardReaderConnectionControllerTests.swift index 6f5e70c072a..0f7218f071b 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/CardPresentPayments/CardReaderConnectionControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/CardPresentPayments/CardReaderConnectionControllerTests.swift @@ -15,10 +15,12 @@ final class CardReaderConnectionControllerTests: XCTestCase { private var storageManager: MockStorageManager! private var analyticsProvider: MockAnalyticsProvider! private var analytics: WooAnalytics! + private var locationService: MockLocationService! override func setUp() { super.setUp() storageManager = MockStorageManager() + locationService = MockLocationService(status: .authorized) let paymentGateway = storageManager.viewStorage.insertNewObject(ofType: StoragePaymentGatewayAccount.self) paymentGateway.update(with: .fake().copy(siteID: sampleSiteID, gatewayID: "woocommerce-payments", isCardPresentEligible: true)) @@ -61,7 +63,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -104,7 +107,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -150,7 +154,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -195,7 +200,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -233,7 +239,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -279,7 +286,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -326,7 +334,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -370,7 +379,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -416,7 +426,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -462,7 +473,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -504,7 +516,8 @@ final class CardReaderConnectionControllerTests: XCTestCase { siteID: sampleSiteID, connectionType: .userInitiated, stores: mockStoresManager, - analytics: analytics) + analytics: analytics), + locationService: locationService ) // When @@ -522,6 +535,156 @@ final class CardReaderConnectionControllerTests: XCTestCase { } assertEqual(.foundReader, source) } + + func test_seachAndConnect_when_locationDenied_and_skipped_to_connect() { + // Given + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [MockCardReader.bbposChipper2XBT()], + sessionManager: SessionManager.testingInstance, + storageManager: storageManager + ) + + let mockKnownReaderProvider = MockKnownReaderProvider(knownReader: nil) + let mockAlerts = MockCardReaderSettingsAlerts(mode: .connectFoundReader) + let mockLocationService = MockLocationService(status: .denied) + mockAlerts.onLocationRequired = { _, skip in + skip() + } + + let controller = CardReaderConnectionController( + forSiteID: sampleSiteID, + storageManager: storageManager, + stores: mockStoresManager, + knownReaderProvider: mockKnownReaderProvider, + alertsPresenter: MockCardPresentPaymentAlertsPresenter(), + alertsProvider: mockAlerts, + configuration: Mocks.configuration, + analyticsTracker: .init(configuration: Mocks.configuration, + siteID: sampleSiteID, + connectionType: .userInitiated, + stores: mockStoresManager, + analytics: analytics), + locationService: mockLocationService + ) + + // When + let connectionResult: CardReaderConnectionResult = waitFor { promise in + controller.searchAndConnect() { result in + if case .success(let connectionResult) = result { + promise(connectionResult) + } + } + } + + // Then + guard case .connected(let reader) = connectionResult else { + return XCTFail("Expected reader to be connected") + } + assertEqual(MockCardReader.bbposChipper2XBT(), reader) + } + + func test_seachAndConnect_when_locationNotDetermined_and_later_authorized() { + // Given + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [MockCardReader.bbposChipper2XBT()], + sessionManager: SessionManager.testingInstance, + storageManager: storageManager + ) + + let mockKnownReaderProvider = MockKnownReaderProvider(knownReader: nil) + let mockAlerts = MockCardReaderSettingsAlerts(mode: .connectFoundReader) + let mockLocationService = MockLocationService(status: .notDetermined) + mockAlerts.onLocationRequestPreAlert = { requestPermission in + mockLocationService.requestPermissionStatus = .authorized + requestPermission() + } + + let controller = CardReaderConnectionController( + forSiteID: sampleSiteID, + storageManager: storageManager, + stores: mockStoresManager, + knownReaderProvider: mockKnownReaderProvider, + alertsPresenter: MockCardPresentPaymentAlertsPresenter(), + alertsProvider: mockAlerts, + configuration: Mocks.configuration, + analyticsTracker: .init(configuration: Mocks.configuration, + siteID: sampleSiteID, + connectionType: .userInitiated, + stores: mockStoresManager, + analytics: analytics), + locationService: mockLocationService + ) + + // When + let connectionResult: CardReaderConnectionResult = waitFor { promise in + controller.searchAndConnect() { result in + if case .success(let connectionResult) = result { + promise(connectionResult) + } + } + } + + // Then + guard case .connected(let reader) = connectionResult else { + return XCTFail("Expected reader to be connected") + } + assertEqual(MockCardReader.bbposChipper2XBT(), reader) + } + + func test_seachAndConnect_when_locationNotDetermined_and_later_denied() { + // Given + let mockStoresManager = MockCardPresentPaymentsStoresManager( + connectedReaders: [], + discoveredReaders: [MockCardReader.bbposChipper2XBT()], + sessionManager: SessionManager.testingInstance, + storageManager: storageManager + ) + + let mockKnownReaderProvider = MockKnownReaderProvider(knownReader: nil) + let mockAlerts = MockCardReaderSettingsAlerts(mode: .connectFoundReader) + let mockLocationService = MockLocationService(status: .notDetermined) + mockAlerts.onLocationRequestPreAlert = { requestPermission in + mockLocationService.requestPermissionStatus = .denied + requestPermission() + } + + mockAlerts.onLocationRequired = { dismiss, _ in + dismiss() + } + + let controller = CardReaderConnectionController( + forSiteID: sampleSiteID, + storageManager: storageManager, + stores: mockStoresManager, + knownReaderProvider: mockKnownReaderProvider, + alertsPresenter: MockCardPresentPaymentAlertsPresenter(), + alertsProvider: mockAlerts, + configuration: Mocks.configuration, + analyticsTracker: .init(configuration: Mocks.configuration, + siteID: sampleSiteID, + connectionType: .userInitiated, + stores: mockStoresManager, + analytics: analytics), + locationService: mockLocationService + ) + + // When + let connectionResult: CardReaderConnectionResult = waitFor(timeout: 6.0) { promise in + controller.searchAndConnect() { result in + if case .success(let connectionResult) = result { + promise(connectionResult) + } + } + } + + // Then + guard case .canceled(let source) = connectionResult else { + return XCTFail("Expected connection to be canceled") + } + assertEqual(.locationPermissionDenied, source) + } } private extension CardReaderConnectionControllerTests { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift index de9dcd24dfa..8f6aadab352 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift @@ -692,7 +692,7 @@ final class DashboardViewModelTests: XCTestCase { await viewModel.onViewAppear() // Then - waitUntil { + await until { scheduler.scheduleNoCampaignReminderCalled == true } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/ProductStockDashboardCardViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/ProductStockDashboardCardViewModelTests.swift index a4afc4fee59..ca8637435ee 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/ProductStockDashboardCardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/ProductStockDashboardCardViewModelTests.swift @@ -82,7 +82,6 @@ final class ProductStockDashboardCardViewModelTests: XCTestCase { let variation = ProductStock.fake().copy(siteID: siteID, productID: 44, parentID: 40) - let thumbnailURL = "https://example.com/image.jpg" let variationReport = ProductReport.fake().copy(productID: 0, // missing product ID happens to some stores variationID: variation.productID, name: "Pizza - Large, Seafood, Spicy", diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayContactlessLimitViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayContactlessLimitViewModelTests.swift index a4c47ac3abe..b1ac6bc84af 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayContactlessLimitViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/AboutTapToPayContactlessLimitViewModelTests.swift @@ -1,11 +1,12 @@ import XCTest +import TestKit @testable import WooCommerce final class AboutTapToPayContactlessLimitViewModelTests: XCTestCase { func test_for_gb_configuration_a_formatted_limit_paragraph_is_provided() { let sut = AboutTapToPayContactlessLimitViewModel(configuration: .init(country: .GB)) - assertEqual("In the United Kingdom, cards may only be used with Tap to Pay for transactions up to £100.", sut.contactlessLimitDetails) + assertThat(sut.contactlessLimitDetails, contains: "In the United Kingdom, you can accept card payments with Tap to Pay for transactions up to £100.") } func test_for_us_configuration_a_fallback_paragraph_is_provided_because_the_view_is_not_shown() { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/Deposits Overview/WooPaymentsPayoutsCurrencyOverviewViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/Deposits Overview/WooPaymentsPayoutsCurrencyOverviewViewModelTests.swift index 2736456b366..0c4d05cc269 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/Deposits Overview/WooPaymentsPayoutsCurrencyOverviewViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/Deposits Overview/WooPaymentsPayoutsCurrencyOverviewViewModelTests.swift @@ -58,11 +58,6 @@ final class WooPaymentsPayoutsCurrencyOverviewViewModelTests: XCTestCase { func test_when_currency_matches_site_settings_amounts_formatted_using_woo_currency_formatter() { // Given - let currencySettings = CurrencySettings(currencyCode: .USD, - currencyPosition: .left, - thousandSeparator: ",", - decimalSeparator: ".", - numberOfDecimals: 2) let overview = WooPaymentsPayoutsOverviewByCurrency.fake().copy(currency: .USD, availableBalance: .init(string: "12.35")) // When @@ -74,11 +69,6 @@ final class WooPaymentsPayoutsCurrencyOverviewViewModelTests: XCTestCase { func test_when_currency_doesnt_match_site_settings_amounts_formatted_using_system_locale_currency_formatter() { // Given - let currencySettings = CurrencySettings(currencyCode: .USD, - currencyPosition: .left, - thousandSeparator: ",", - decimalSeparator: ".", - numberOfDecimals: 2) let overview = WooPaymentsPayoutsOverviewByCurrency.fake().copy(currency: .CAD, availableBalance: .init(string: "12.35")) // When diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayAwarenessMomentDeterminerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayAwarenessMomentDeterminerTests.swift new file mode 100644 index 00000000000..93331b7bd52 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayAwarenessMomentDeterminerTests.swift @@ -0,0 +1,202 @@ +import Testing +import Foundation +@testable import WooCommerce + +struct TapToPayAwarenessMomentDeterminerTests { + private let cardReaderSupportDeterminer: MockCardReaderSupportDeterminer + private let cardPresentPaymentsOnboardingUseCase: MockCardPresentPaymentsOnboardingUseCase + private let featureFlagService: MockFeatureFlagService + private let userDefaults: MockUserDefaults + private let sut: TapToPayAwarenessMomentDeterminer + + init() { + cardReaderSupportDeterminer = MockCardReaderSupportDeterminer() + cardPresentPaymentsOnboardingUseCase = MockCardPresentPaymentsOnboardingUseCase(initial: .completed(plugin: .wcPayOnly)) + featureFlagService = MockFeatureFlagService() + userDefaults = MockUserDefaults() + + sut = TapToPayAwarenessMomentDeterminer(cardReaderSupportDeterminer: cardReaderSupportDeterminer, + cardPresentPaymentsOnboarding: cardPresentPaymentsOnboardingUseCase, + featureFlagService: featureFlagService, + userDefaults: userDefaults) + + } + + // MARK: - Should Present + + @Test func shouldPresent_when_featureFlagDisabled() async { + // Given + featureFlagService.tapToPayEducation = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_alreadyPresented() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = true + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_firstAttempt() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_onboardingNotCompleted() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .pluginNotInstalled + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_deviceNotSupportingTapToPay() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .completed(plugin: .wcPayOnly) + cardReaderSupportDeterminer.shouldReturnDeviceSupportsLocalMobileReader = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_siteNotSupportingTapToPay() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .completed(plugin: .wcPayOnly) + cardReaderSupportDeterminer.shouldReturnDeviceSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnSiteSupportsLocalMobileReader = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_previousTapToPayUsageDetected() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .completed(plugin: .wcPayOnly) + cardReaderSupportDeterminer.shouldReturnDeviceSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnSiteSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnHasPreviousTapToPayUsage = true + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(!shouldPresent) + } + + @Test func shouldPresent_when_noPreviousPresentation_secondAttempt_onboarded_supportsTTP() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .completed(plugin: .wcPayOnly) + cardReaderSupportDeterminer.shouldReturnDeviceSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnSiteSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnHasPreviousTapToPayUsage = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(shouldPresent) + } + + @Test func shouldPresent_when_noPreviousPresentation_secondAttempt_codPaymentGatewayNotSetUp_supportsTTP() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = true + cardPresentPaymentsOnboardingUseCase.state = .codPaymentGatewayNotSetUp(plugin: .wcPay) + cardReaderSupportDeterminer.shouldReturnDeviceSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnSiteSupportsLocalMobileReader = true + cardReaderSupportDeterminer.shouldReturnHasPreviousTapToPayUsage = false + + // When + let shouldPresent = await sut.shouldPresent() + + // Then + #expect(shouldPresent) + } + + // MARK: - Attempt + + @Test func hasFirstAttempt_when_shouldPresent() async { + // Given + featureFlagService.tapToPayEducation = true + userDefaults.hasPreviousPresentation = false + userDefaults.hasFirstAttempt = false + + // When shouldPresent called + _ = await sut.shouldPresent() + + // Then + #expect(userDefaults.hasFirstAttempt) + } +} + +// MARK: - Mock User Defaults for Tap to Pay Awareness Moment Determiner + +private class MockUserDefaults: UserDefaults { + var hasPreviousPresentation: Bool = false + var hasFirstAttempt: Bool = false + + override func bool(forKey defaultName: String) -> Bool { + switch defaultName { + case UserDefaults.Key.tapToPayAwarenessMomentPresented.rawValue: + return hasPreviousPresentation + case UserDefaults.Key.tapToPayAwarenessMomentFirstLaunchCompleted.rawValue: + return hasFirstAttempt + default: + return false + } + } + + override func set(_ value: Bool, forKey defaultName: String) { + switch defaultName { + case UserDefaults.Key.tapToPayAwarenessMomentPresented.rawValue: + hasPreviousPresentation = value + case UserDefaults.Key.tapToPayAwarenessMomentFirstLaunchCompleted.rawValue: + hasFirstAttempt = value + default: + break + } + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayEducationViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayEducationViewModelTests.swift index 930689847ea..46b41bdf87f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayEducationViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/In-Person Payments/TapToPayEducationViewModelTests.swift @@ -4,16 +4,14 @@ import Combine struct TapToPayEducationViewModelTests { private let cardReaderSupportDeterminer: MockCardReaderSupportDeterminer - private let cardPresentPaymentsOnboardingUseCase: MockCardPresentPaymentsOnboardingUseCase init() { cardReaderSupportDeterminer = MockCardReaderSupportDeterminer() - cardPresentPaymentsOnboardingUseCase = MockCardPresentPaymentsOnboardingUseCase(initial: .completed(plugin: .wcPayOnly)) } private func create(flow: TapToPayEducationViewModel.Flow, steps: [TapToPayEducationStepViewModel]? = nil, - onDismiss: @escaping () -> Void = {}) -> TapToPayEducationViewModel { + completion: @escaping (TapToPayEducationResult) -> Void = { _ in }) -> TapToPayEducationViewModel { let steps = steps ?? [.init(title: "1", imageName: "", description: ""), .init(title: "2", imageName: "", description: ""), .init(title: "3", imageName: "", description: "")] @@ -21,18 +19,14 @@ struct TapToPayEducationViewModelTests { steps: steps, siteID: 123, cardReaderSupportDeterminer: cardReaderSupportDeterminer, - cardPresentPaymentsOnboardingUseCase: cardPresentPaymentsOnboardingUseCase, - onDismiss: onDismiss) + completion: completion) } // MARK: - Primary Action @Test func primaryAction_when_onboarding() { // Given - var isDismissed = false - let sut = create(flow: .onboarding, onDismiss: { - isDismissed = true - }) + let sut = create(flow: .onboarding) // When & Then #expect(sut.primaryAction.title == "Next") @@ -47,16 +41,16 @@ struct TapToPayEducationViewModelTests { #expect(sut.selectedStep == 2) sut.primaryAction.action() - #expect(isDismissed) + #expect(sut.dismiss) } @Test func primaryAction_when_about_and_no_previous_tap_to_pay_usage() { // Given cardReaderSupportDeterminer.shouldReturnHasPreviousTapToPayUsage = false - var isDismissed = false - let sut = create(flow: .about, onDismiss: { - isDismissed = true - }) + var result: TapToPayEducationResult? + let sut = create(flow: .about) { + result = $0 + } // When & Then #expect(sut.primaryAction.title == "Next") @@ -71,17 +65,18 @@ struct TapToPayEducationViewModelTests { #expect(sut.selectedStep == 2) sut.primaryAction.action() - #expect(!isDismissed) - #expect(sut.showingSetUpFlow) + #expect(sut.dismiss) + sut.onDisappear() + #expect(result == .setUpTapToPay) } @Test func primaryAction_when_about_and_has_previous_tap_to_pay_usage() async throws { // Given cardReaderSupportDeterminer.shouldReturnHasPreviousTapToPayUsage = true - var isDismissed = false - let sut = create(flow: .about, onDismiss: { - isDismissed = true - }) + var result: TapToPayEducationResult? + let sut = create(flow: .about) { + result = $0 + } var cancellables = Set() await withCheckedContinuation { continuation in @@ -107,8 +102,9 @@ struct TapToPayEducationViewModelTests { #expect(sut.selectedStep == 2) sut.primaryAction.action() - #expect(isDismissed) - #expect(!sut.showingSetUpFlow) + #expect(sut.dismiss) + sut.onDisappear() + #expect(result == .done) } // MARK: - Secondary Action diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/ThemeSettingViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/ThemeSettingViewModelTests.swift index cd6d9a14238..70c1f1dad8c 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/ThemeSettingViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Settings/ThemeSettingViewModelTests.swift @@ -70,7 +70,7 @@ final class ThemeSettingViewModelTests: XCTestCase { stores: stores, themeInstaller: themeInstaller) // Then - waitUntil { + await until { themeInstaller.installPendingThemeCalled == true } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Feature Highlight/TooltipPresenterTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Feature Highlight/TooltipPresenterTests.swift index 916e3fad4ee..a68dffa1987 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Feature Highlight/TooltipPresenterTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Feature Highlight/TooltipPresenterTests.swift @@ -9,20 +9,23 @@ final class TooltipPresenterTests: XCTestCase { let containerView = UIView() let toolTip = Tooltip() - waitForExpectation { exp in - let sut = TooltipPresenter(containerView: containerView, - tooltip: toolTip, - target: .point(tooltipTargetPoint), - primaryTooltipAction: { - // Then - exp.fulfill() - }) - - sut.showTooltip() - - // When - sut.dismissTooltip() - } + var primaryTooltipActionCalled = false + let sut = TooltipPresenter(containerView: containerView, + tooltip: toolTip, + target: .point(tooltipTargetPoint), + animation: TooltipAnimationMock.self, + primaryTooltipAction: { + // Then + primaryTooltipActionCalled = true + }) + + sut.showTooltip() + + // When + sut.dismissTooltip() + + // Then + XCTAssertTrue(primaryTooltipActionCalled) } // MARK: `removeTooltip` @@ -32,23 +35,23 @@ final class TooltipPresenterTests: XCTestCase { let containerView = UIView() let toolTip = Tooltip() - waitForExpectation { exp in - exp.isInverted = true + var primaryTooltipActionCalled = false + let sut = TooltipPresenter(containerView: containerView, + tooltip: toolTip, + target: .point(tooltipTargetPoint), + animation: TooltipAnimationMock.self, + primaryTooltipAction: { + // Then + primaryTooltipActionCalled = true + }) - let sut = TooltipPresenter(containerView: containerView, - tooltip: toolTip, - target: .point(tooltipTargetPoint), - primaryTooltipAction: { - // Then - // `primaryTooltipAction` should not be fired - exp.fulfill() - }) + sut.showTooltip() - sut.showTooltip() + // When + sut.removeTooltip() - // When - sut.removeTooltip() - } + // Then + XCTAssertFalse(primaryTooltipActionCalled) } } @@ -57,3 +60,14 @@ private extension TooltipPresenterTests { .zero } } + +private class TooltipAnimationMock: TooltipAnimation { + static func animate(withDuration duration: TimeInterval, + delay: TimeInterval, + options: UIView.AnimationOptions, + animations: @escaping () -> Void, + completion: ((Bool) -> Void)?) { + animations() + completion?(true) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/HubMenu/HubMenuViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/HubMenu/HubMenuViewModelTests.swift index b7499dc96a9..c822e15d1b2 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/HubMenu/HubMenuViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/HubMenu/HubMenuViewModelTests.swift @@ -21,6 +21,87 @@ final class HubMenuViewModelTests: XCTestCase { waitForExpectations(timeout: Constants.expectationTimeout) } + @MainActor + func test_viewDidAppear_triggers_blaze_eligibility_check_only_if_site_is_ineligible() { + // Given + let stores = MockStoresManager(sessionManager: .makeForTesting()) + // Setting site ID is required before setting `Site`. + stores.updateDefaultStore(storeID: sampleSiteID) + stores.updateDefaultStore(.fake().copy(siteID: sampleSiteID)) + + let blazeEligibilityChecker = MockBlazeEligibilityChecker(isSiteEligible: false) + let viewModel = HubMenuViewModel(siteID: sampleSiteID, + tapToPayBadgePromotionChecker: TapToPayBadgePromotionChecker(), + stores: stores, + blazeEligibilityChecker: blazeEligibilityChecker) + waitUntil { + // The first check is triggered by `updateMenuItemEligibility` + blazeEligibilityChecker.siteEligibilityCheckCount == 1 + } + + // When + viewModel.viewDidAppear() + + // Then + waitUntil { + blazeEligibilityChecker.siteEligibilityCheckCount == 2 + } + + // When + blazeEligibilityChecker.updateSiteEligibility(true) + viewModel.viewDidAppear() + + // Then + waitUntil { + blazeEligibilityChecker.siteEligibilityCheckCount == 3 + } + + // When + viewModel.viewDidAppear() + + // Then + waitForExpectation(timeout: 2) { expectation in + expectation.isInverted = true + if blazeEligibilityChecker.siteEligibilityCheckCount == 4 { + expectation.fulfill() // This should not happen + } + } + } + + @MainActor + func test_createGoogleAdsCampaignCoordinator_sets_correct_value_for_shouldStartCampaignCreation() { + // Given + let stores = MockStoresManager(sessionManager: .makeForTesting()) + // Setting site ID is required before setting `Site`. + stores.updateDefaultStore(storeID: sampleSiteID) + stores.updateDefaultStore(.fake().copy(siteID: sampleSiteID)) + + let checker = MockGoogleAdsEligibilityChecker(isEligible: true) + let viewModel = HubMenuViewModel(siteID: sampleSiteID, + tapToPayBadgePromotionChecker: TapToPayBadgePromotionChecker(), + stores: stores, + googleAdsEligibilityChecker: checker) + + // When + let navigationController = UINavigationController() + let coordinator = viewModel.createGoogleAdsCampaignCoordinator(with: navigationController) + + // Then + XCTAssertFalse(coordinator.shouldAuthenticateAdminPage) + XCTAssertTrue(coordinator.shouldStartCampaignCreation) + + // When + mockGoogleAdsCampaignFetch(with: .success([GoogleAdsCampaign.fake()]), for: stores) + viewModel.refreshGoogleAdsCampaignCheck() + + // Then + waitUntil { + viewModel.hasGoogleAdsCampaigns == true + } + let updatedCoordinator = viewModel.createGoogleAdsCampaignCoordinator(with: navigationController) + XCTAssertFalse(updatedCoordinator.shouldStartCampaignCreation) + } + @MainActor func test_menuElements_do_not_include_inbox_when_feature_flag_is_off() { // Given @@ -493,7 +574,7 @@ final class HubMenuViewModelTests: XCTestCase { let generalAppSettings = try mockGeneralAppSettingsStorage(isInAppPurchaseEnabled: true) let blazeEligibilityChecker = MockBlazeEligibilityChecker(isSiteEligible: true) let googleAdsEligibilityChecker = MockGoogleAdsEligibilityChecker(isEligible: true) - var inboxEligibilityChecker = MockInboxEligibilityChecker() + let inboxEligibilityChecker = MockInboxEligibilityChecker() inboxEligibilityChecker.isEligible = true let stores = MockStoresManager(sessionManager: .makeForTesting()) @@ -523,14 +604,15 @@ final class HubMenuViewModelTests: XCTestCase { .coupons: HubMenuViewModel.Coupons(), .reviews: HubMenuViewModel.Reviews(), .inbox: HubMenuViewModel.Inbox(), - .customers: HubMenuViewModel.Customers(), - .pointOfSales: HubMenuViewModel.PointOfSaleEntryPoint() + .customers: HubMenuViewModel.Customers() ] /// Counting the cases to ensure new cases are tested. + /// POS row/element does not use the push/pop navigation destination like other elements. + let nonNavigationDestinationElementsCount = 1 viewModel.setupMenuElements() waitUntil { - expectedMenusAndDestinations.count == viewModel.settingsElements.count + viewModel.generalElements.count + expectedMenusAndDestinations.count == viewModel.settingsElements.count + viewModel.generalElements.count - nonNavigationDestinationElementsCount } for (expected, menuItem) in expectedMenusAndDestinations { @@ -708,4 +790,15 @@ private extension HubMenuViewModelTests { try storage.saveSettings(settings) return storage } + + func mockGoogleAdsCampaignFetch(with result: Result<[GoogleAdsCampaign], Error>, for stores: MockStoresManager) { + stores.whenReceivingAction(ofType: GoogleAdsAction.self) { action in + switch action { + case .fetchAdsCampaigns(_, let onCompletion): + onCompletion(result) + default: + break + } + } + } } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift index 578d2239ac4..a2395339f24 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/MainTabBarControllerTests.swift @@ -225,13 +225,11 @@ final class MainTabBarControllerTests: XCTestCase { } completion(.success(ProductReviewFromNoteParcelFactory().parcel(metaSiteID: 606))) - // Assert + // HubMenuViewController should be pushed, and be the MainTabBarController visible index waitUntil { - hubMenuNavigationController.viewControllers.count != 0 + hubMenuNavigationController.topViewController is HubMenuViewController && + tabBarController.selectedIndex == WooTab.hubMenu.visibleIndex() } - // HubMenuViewController should be pushed, and be the MainTabBarController visible index - assertThat(hubMenuNavigationController.topViewController, isAnInstanceOf: HubMenuViewController.self) - XCTAssertEqual(tabBarController.selectedIndex, WooTab.hubMenu.visibleIndex()) } func test_when_receiving_product_image_upload_error_a_notice_is_enqueued() throws { @@ -357,8 +355,10 @@ final class MainTabBarControllerTests: XCTestCase { } // Then - let productNavigationController = try XCTUnwrap(productsNavigationController.presentedViewController as? UINavigationController) - assertThat(productNavigationController.topViewController, isAnInstanceOf: ProductLoaderViewController.self) + waitUntil { + let productNavigationController = productsNavigationController.presentedViewController as? UINavigationController + return productNavigationController?.topViewController is ProductLoaderViewController + } } // MARK: - Analytics @@ -494,8 +494,11 @@ final class MainTabBarControllerTests: XCTestCase { let tabContainerController = try XCTUnwrap(tabBarController.selectedViewController as? TabContainerController) let ordersSplitViewWrapper = try XCTUnwrap(tabContainerController.wrappedController as? OrdersSplitViewWrapperController) let splitViewController = try XCTUnwrap(ordersSplitViewWrapper.children.first as? UISplitViewController) - let secondaryViewController = try XCTUnwrap((splitViewController.viewController(for: .secondary) as? UINavigationController)?.topViewController) - assertThat(secondaryViewController, isAnInstanceOf: OrderLoaderViewController.self) + + waitUntil { + let secondaryViewController = (splitViewController.viewController(for: .secondary) as? UINavigationController)?.topViewController + return secondaryViewController is OrderLoaderViewController + } // Resets the tab bar controller mock at the end of the test. TestingAppDelegate.mockTabBarController = nil @@ -537,8 +540,10 @@ final class MainTabBarControllerTests: XCTestCase { let tabContainerController = try XCTUnwrap(tabBarController.selectedViewController as? TabContainerController) let ordersSplitViewWrapper = try XCTUnwrap(tabContainerController.wrappedController as? OrdersSplitViewWrapperController) let splitViewController = try XCTUnwrap(ordersSplitViewWrapper.children.first as? UISplitViewController) - let secondaryViewController = try XCTUnwrap((splitViewController.viewController(for: .secondary) as? UINavigationController)?.topViewController) - assertThat(secondaryViewController, isAnInstanceOf: OrderLoaderViewController.self) + waitUntil { + let secondaryViewController = (splitViewController.viewController(for: .secondary) as? UINavigationController)?.topViewController + return secondaryViewController is OrderLoaderViewController + } // Resets the tab bar controller mock at the end of the test. TestingAppDelegate.mockTabBarController = nil diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSectionViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSectionViewModelTests.swift new file mode 100644 index 00000000000..b5c829fed41 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/CustomAmounts/OrderCustomAmountsSectionViewModelTests.swift @@ -0,0 +1,24 @@ +import Testing + +@testable import WooCommerce +import WooFoundation + +struct OrderCustomAmountsSectionViewModelTests { + @Test + func when_created_with_usd_currencySettings_currencySymbol_returns_dollar() throws { + // Given + let usdSettings = CurrencySettings(currencyCode: .USD, currencyPosition: .left, thousandSeparator: "", decimalSeparator: "", numberOfDecimals: 2) + let sut = OrderCustomAmountsSectionViewModel(currencySettings: usdSettings) + // When, Then + #expect(sut.currencySymbol == "$") + } + + @Test + func when_created_with_gbp_currencySettings_currencySymbol_returns_pound() throws { + // Given + let gbpSettings = CurrencySettings(currencyCode: .GBP, currencyPosition: .left, thousandSeparator: "", decimalSeparator: "", numberOfDecimals: 2) + let sut = OrderCustomAmountsSectionViewModel(currencySettings: gbpSettings) + // When, Then + #expect(sut.currencySymbol == "£") + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/EditableOrderViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/EditableOrderViewModelTests.swift index a7dcf00dc33..e3871c7b376 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/EditableOrderViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/EditableOrderViewModelTests.swift @@ -337,6 +337,60 @@ final class EditableOrderViewModelTests: XCTestCase { } } + func test_doneButtonType_when_custom_amount_using_onRecalculateButtonTap_sync_approach() throws { + // Given + let viewModel = EditableOrderViewModel(siteID: sampleSiteID, + storageManager: storageManager, + featureFlagService: MockFeatureFlagService(sideBySideViewForOrderForm: true)) + viewModel.toggleProductSelectorVisibility() + + viewModel.selectionSyncApproach = .onRecalculateButtonTap + + // When + let addCustomAmountViewModel = viewModel.addCustomAmountViewModel(with: .fixedAmount) + addCustomAmountViewModel.name = "Test" + addCustomAmountViewModel.doneButtonPressed() + + // Then + switch viewModel.doneButtonType { + case .create: + // Success – we just don't care about the `loading` parameter + break + default: + XCTFail("Unexpected doneButtonType") + } + } + + func test_doneButtonType_when_toggled_product_selection_and_custom_amount_using_onRecalculateButtonTap_sync_approach() throws { + // Given + let product = Product.fake().copy(siteID: sampleSiteID, productID: sampleProductID, purchasable: true) + storageManager.insertSampleProduct(readOnlyProduct: product) + let viewModel = EditableOrderViewModel(siteID: sampleSiteID, + storageManager: storageManager, + featureFlagService: MockFeatureFlagService(sideBySideViewForOrderForm: true)) + viewModel.toggleProductSelectorVisibility() + + viewModel.selectionSyncApproach = .onRecalculateButtonTap + let productSelectorViewModel = try XCTUnwrap(viewModel.productSelectorViewModel) + + // When we add custom amount + let addCustomAmountViewModel = viewModel.addCustomAmountViewModel(with: .fixedAmount) + addCustomAmountViewModel.name = "Test" + addCustomAmountViewModel.doneButtonPressed() + // and toggle product selection + productSelectorViewModel.changeSelectionStateForProduct(with: product.productID, selected: true) + productSelectorViewModel.changeSelectionStateForProduct(with: product.productID, selected: false) + + // Then + switch viewModel.doneButtonType { + case .create: + // Success – we just don't care about the `loading` parameter + break + default: + XCTFail("Unexpected doneButtonType") + } + } + func test_view_model_is_updated_when_product_is_added_to_order_using_buttonTap_sync_approach_then_changes_to_immediate() throws { // Given let product = Product.fake().copy(siteID: sampleSiteID, productID: sampleProductID, purchasable: true) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/OrderListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/OrderListViewModelTests.swift index 1f82c6946b7..96b6e012a84 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/OrderListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/OrderListViewModelTests.swift @@ -21,7 +21,7 @@ final class OrderListViewModelTests: XCTestCase { storageManager.viewStorage } - private var cancellables = Set() + private var subscriptions = Set() override func setUp() { super.setUp() @@ -34,10 +34,10 @@ final class OrderListViewModelTests: XCTestCase { } override func tearDown() { - cancellables.forEach { + subscriptions.forEach { $0.cancel() } - cancellables.removeAll() + subscriptions.removeAll() super.tearDown() } @@ -253,7 +253,7 @@ final class OrderListViewModelTests: XCTestCase { // MARK: - Banner visibility - func test_banner_should_not_be_shown_when_there_is_no_error() { + func test_banner_should_not_be_shown_when_there_is_no_error() async { // Given let viewModel = OrderListViewModel(siteID: siteID, stores: stores, filters: nil) stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in @@ -269,24 +269,19 @@ final class OrderListViewModelTests: XCTestCase { viewModel.activate() // Then - waitUntil { - viewModel.topBanner == .none - } + XCTAssert(viewModel.topBanner == .none) } - func test_storing_error_shows_error_banner() { + func test_storing_error_shows_error_banner() async { // Given let expectedError = MockError() let viewModel = OrderListViewModel(siteID: siteID, filters: nil) // When - viewModel.dataLoadingError = expectedError viewModel.activate() + viewModel.dataLoadingError = expectedError - // Then - waitUntil { - viewModel.topBanner == .error(expectedError) - } + XCTAssert(viewModel.topBanner == .error(expectedError)) } // MARK: - Filters Applied @@ -466,7 +461,7 @@ private extension OrderListViewModelTests { // The first snapshot is dropped because it's just the default empty one. viewModel.snapshot.dropFirst().sink { snapshot in promise(snapshot) - }.store(in: &self.cancellables) + }.store(in: &self.subscriptions) viewModel.activate() } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModelTests.swift index d879c61f326..8a58099e518 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Payment Methods/PaymentMethodsViewModelTests.swift @@ -1015,6 +1015,101 @@ final class PaymentMethodsViewModelTests: XCTestCase { XCTAssertEqual(siteID, order.siteID) XCTAssertEqual(orderID, order.orderID) } + + func test_collectPayment_when_ttp_payment_error_then_onFailure_called() { + // Given + storage.insertSampleOrder(readOnlyOrder: .fake()) + stores.whenReceivingAction(ofType: CardPresentPaymentAction.self) { action in + if case let .selectedPaymentGatewayAccount(onCompletion) = action { + onCompletion(PaymentGatewayAccount.fake()) + } + } + + let useCase = MockCollectOrderPaymentUseCase(onCollectResult: .failure(NSError(domain: "Error", code: 0, userInfo: nil))) + let onboardingPresenter = MockCardPresentPaymentsOnboardingPresenter() + let dependencies = Dependencies( + cardPresentPaymentsOnboardingPresenter: onboardingPresenter, + stores: stores, + storage: storage) + let viewModel = PaymentMethodsViewModel(total: "12", + formattedTotal: "$12.00", + flow: .simplePayment, + channel: .storeManagement, + dependencies: dependencies) + + // When + var onFailureCalled: Bool = false + viewModel.collectPayment(using: .localMobile, on: UIViewController(), useCase: useCase, onSuccess: {}, onFailure: { + onFailureCalled = true + }) + + // Then + XCTAssertTrue(onFailureCalled) + } + + func test_collectPayment_when_ttp_payment_error_requiresFallbackPaymentMethod_then_onFailure_not_called() { + // Given + storage.insertSampleOrder(readOnlyOrder: .fake()) + stores.whenReceivingAction(ofType: CardPresentPaymentAction.self) { action in + if case let .selectedPaymentGatewayAccount(onCompletion) = action { + onCompletion(PaymentGatewayAccount.fake()) + } + } + + let error: CardReaderServiceError = .paymentCapture(underlyingError: .paymentDeclinedByPaymentProcessorAPI(declineReason: .pinRequired)) + let useCase = MockCollectOrderPaymentUseCase(onCollectResult: .failure(error)) + let onboardingPresenter = MockCardPresentPaymentsOnboardingPresenter() + let dependencies = Dependencies( + cardPresentPaymentsOnboardingPresenter: onboardingPresenter, + stores: stores, + storage: storage) + let viewModel = PaymentMethodsViewModel(total: "12", + formattedTotal: "$12.00", + flow: .simplePayment, + channel: .storeManagement, + dependencies: dependencies) + + // When + var onFailureCalled: Bool = false + viewModel.collectPayment(using: .localMobile, on: UIViewController(), useCase: useCase, onSuccess: {}, onFailure: { + onFailureCalled = true + }) + + // Then + XCTAssertFalse(onFailureCalled) + } + + func test_collectPayment_when_card_reader_payment_error_requiresFallbackPaymentMethod_then_onFailure_called() { + // Given + storage.insertSampleOrder(readOnlyOrder: .fake()) + stores.whenReceivingAction(ofType: CardPresentPaymentAction.self) { action in + if case let .selectedPaymentGatewayAccount(onCompletion) = action { + onCompletion(PaymentGatewayAccount.fake()) + } + } + + let error: CardReaderServiceError = .paymentCapture(underlyingError: .paymentDeclinedByPaymentProcessorAPI(declineReason: .pinRequired)) + let useCase = MockCollectOrderPaymentUseCase(onCollectResult: .failure(error)) + let onboardingPresenter = MockCardPresentPaymentsOnboardingPresenter() + let dependencies = Dependencies( + cardPresentPaymentsOnboardingPresenter: onboardingPresenter, + stores: stores, + storage: storage) + let viewModel = PaymentMethodsViewModel(total: "12", + formattedTotal: "$12.00", + flow: .simplePayment, + channel: .storeManagement, + dependencies: dependencies) + + // When + var onFailureCalled: Bool = false + viewModel.collectPayment(using: .bluetoothScan, on: UIViewController(), useCase: useCase, onSuccess: {}, onFailure: { + onFailureCalled = true + }) + + // Then + XCTAssertTrue(onFailureCalled) + } } private extension PaymentMethodsViewModelTests { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/AI/ProductSharingMessageGenerationViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/AI/ProductSharingMessageGenerationViewModelTests.swift index f6f462c9d9a..9080dc57e7c 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/AI/ProductSharingMessageGenerationViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/AI/ProductSharingMessageGenerationViewModelTests.swift @@ -476,7 +476,7 @@ final class ProductSharingMessageGenerationViewModelTests: XCTestCase { // When // Regeneration attempt await viewModel.generateShareMessage() - waitUntil { + await until { viewModel.generationInProgress == false } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewController+ProductImageUploaderTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewController+ProductImageUploaderTests.swift index 19f78e608b4..ca4cbc6d63f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewController+ProductImageUploaderTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Edit Product/ProductFormViewController+ProductImageUploaderTests.swift @@ -79,23 +79,42 @@ final class ProductFormViewController_ProductImageUploaderTests: XCTestCase { productImageActionHandler: actionHandler, presentationStyle: .navigationStack, productImageUploader: productImageUploader) - let rootNavigationController = UINavigationController(rootViewController: .init()) + let rootNavigationController = MockNavigationController(rootViewController: .init()) window.rootViewController = rootNavigationController // When rootNavigationController.pushViewController(productForm, animated: false) - - waitUntil { - rootNavigationController.viewControllers.count == 2 - } - + // And rootNavigationController.popViewController(animated: false) - waitUntil { - rootNavigationController.viewControllers.count == 1 - } - // Then XCTAssertTrue(productImageUploader.startEmittingErrorsWasCalled) } } + +// MARK: - MockNavigationController +/// Popping with UINavigationController doesn't work reliably in unit tests and doesn't always result in viewWillDisappear being called. +/// Created a mock to simulate the behavior of UINavigationController. +/// +private class MockNavigationController: UINavigationController { + private var pushedViewControllers: [UIViewController] = [] + private var isBeingDismissedToReturn: Bool = false + + override var isBeingDismissed: Bool { + isBeingDismissedToReturn + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + super.pushViewController(viewController, animated: true) + isBeingDismissedToReturn = false + pushedViewControllers.append(viewController) + } + + @discardableResult + override func popViewController(animated: Bool) -> UIViewController? { + let viewController = pushedViewControllers.popLast() + isBeingDismissedToReturn = true + viewController?.viewWillDisappear(animated) + return super.popViewController(animated: animated) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/WordPressMediaLibraryImagePickerCoordinatorTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/WordPressMediaLibraryImagePickerCoordinatorTests.swift index 9deec18a06f..92ae61e52cf 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/WordPressMediaLibraryImagePickerCoordinatorTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Products/Media/WordPressMediaLibraryImagePickerCoordinatorTests.swift @@ -158,6 +158,11 @@ final class WordPressMediaLibraryImagePickerCoordinatorTests: XCTestCase { self.sourceViewController.presentedViewController != nil } let mediaPicker = try XCTUnwrap(self.sourceViewController.presentedViewController as? WordPressMediaLibraryPickerViewController) + + self.waitUntil { + return mediaPicker.presentationController?.delegate != nil + } + mediaPicker.presentationController?.delegate?.presentationControllerDidDismiss?(.init(presentedViewController: .init(), presenting: nil)) } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift index e82f074b5ed..6fceb3fc712 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/POS/POSEligibilityCheckerTests.swift @@ -5,7 +5,6 @@ import Yosemite @testable import WooCommerce final class POSEligibilityCheckerTests: XCTestCase { - private var onboardingUseCase: MockCardPresentPaymentsOnboardingUseCase! private var stores: MockStoresManager! private var storageManager: MockStorageManager! private var siteSettings: SelectedSiteSettings! @@ -15,7 +14,6 @@ final class POSEligibilityCheckerTests: XCTestCase { override func setUp() { super.setUp() - onboardingUseCase = MockCardPresentPaymentsOnboardingUseCase(initial: .completed(plugin: .wcPayPreferred)) stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) stores.updateDefaultStore(storeID: siteID) setupWooCommerceVersion() @@ -27,7 +25,6 @@ final class POSEligibilityCheckerTests: XCTestCase { siteSettings = nil storageManager = nil stores = nil - onboardingUseCase = nil super.tearDown() } @@ -37,7 +34,6 @@ final class POSEligibilityCheckerTests: XCTestCase { setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -54,7 +50,6 @@ final class POSEligibilityCheckerTests: XCTestCase { setupCountry(country: .us) accountWhitelistedInBackend(false) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -71,7 +66,6 @@ final class POSEligibilityCheckerTests: XCTestCase { setupCountry(country: .us) accountWhitelistedInBackend(false) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -89,7 +83,6 @@ final class POSEligibilityCheckerTests: XCTestCase { [UIUserInterfaceIdiom.phone, UIUserInterfaceIdiom.mac, UIUserInterfaceIdiom.tv, UIUserInterfaceIdiom.carPlay] .forEach { userInterfaceIdiom in let checker = POSEligibilityChecker(userInterfaceIdiom: userInterfaceIdiom, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -109,27 +102,6 @@ final class POSEligibilityCheckerTests: XCTestCase { setupCountry(country: country) accountWhitelistedInBackend(true) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - - // Then - XCTAssertFalse(isEligible) - } - } - - func test_is_eligible_when_non_us_site_then_returns_false_with_onboarding_feature_enabled() { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true, paymentsOnboardingInPointOfSale: true) - [Country.ca, Country.es, Country.gb].forEach { country in - // When - setupCountry(country: country) - accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -147,24 +119,6 @@ final class POSEligibilityCheckerTests: XCTestCase { setupCountry(country: .us) accountWhitelistedInBackend(true) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, - siteSettings: siteSettings, - currencySettings: Fixtures.nonUSDCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - - // Then - XCTAssertFalse(isEligible) - } - - func test_when_non_usd_currency_then_isEligible_returns_false_with_onboarding_feature_enabled() { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true, paymentsOnboardingInPointOfSale: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.nonUSDCurrencySettings, stores: stores, @@ -180,7 +134,6 @@ final class POSEligibilityCheckerTests: XCTestCase { let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: false) setupCountry(country: .us) let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -191,66 +144,6 @@ final class POSEligibilityCheckerTests: XCTestCase { XCTAssertFalse(isEligible) } - func test_is_eligible_when_onboarding_state_is_not_completed_wcpay_then_returns_false() throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - XCTAssertTrue(isEligible) - - // When onboarding state is loading - onboardingUseCase.state = .loading - // Then - XCTAssertFalse(isEligible) - - // When onboarding state is stripeOnly - onboardingUseCase.state = .completed(plugin: .stripeOnly) - // Then - XCTAssertFalse(isEligible) - - // When onboarding state is wcPayOnly - onboardingUseCase.state = .completed(plugin: .wcPayOnly) - // Then - XCTAssertTrue(isEligible) - - // When onboarding state is stripePreferred - onboardingUseCase.state = .completed(plugin: .stripePreferred) - // Then - XCTAssertFalse(isEligible) - - // When onboarding state is pluginNotInstalled - onboardingUseCase.state = .pluginNotInstalled - // Then - XCTAssertFalse(isEligible) - } - - func test_is_eligible_when_onboarding_state_is_not_completed_and_onboarding_feature_enabled_then_returns_true() throws { - // Given - let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true, paymentsOnboardingInPointOfSale: true) - setupCountry(country: .us) - accountWhitelistedInBackend(true) - let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, - siteSettings: siteSettings, - currencySettings: Fixtures.usdCurrencySettings, - stores: stores, - featureFlagService: featureFlagService) - checker.isEligible.assign(to: &$isEligible) - - // When - onboardingUseCase.state = .pluginNotInstalled - - // Then - XCTAssertTrue(isEligible) - } - func test_is_eligible_when_WooCommerce_version_is_below_6_6_then_returns_false() throws { // Given let featureFlagService = MockFeatureFlagService(isPointOfSaleEnabled: true) @@ -261,7 +154,6 @@ final class POSEligibilityCheckerTests: XCTestCase { // When let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, @@ -283,7 +175,6 @@ final class POSEligibilityCheckerTests: XCTestCase { // When let checker = POSEligibilityChecker(userInterfaceIdiom: .pad, - cardPresentPaymentsOnboarding: onboardingUseCase, siteSettings: siteSettings, currencySettings: Fixtures.usdCurrencySettings, stores: stores, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddCustomPackageViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddCustomPackageViewModelTests.swift index 26a27ce0a6a..fec1fd11030 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddCustomPackageViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddCustomPackageViewModelTests.swift @@ -9,8 +9,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // Then @@ -18,33 +16,12 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { viewModel.checkDefaultInitProperties() } - @MainActor - func test_it_inits_with_store_options() { - // Given/When - let expectedDimensionUnit = "in" - let expectedWeightUnit = "kg" - let siteID: Int64 = 1234 - let mockStores = MockStoresManager(sessionManager: .testingInstance) - let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: expectedDimensionUnit, - weightUnit: expectedWeightUnit, - stores: mockStores) - - // Then - XCTAssertNotNil(viewModel) - viewModel.checkDefaultInitProperties() - XCTAssertEqual(viewModel.dimensionsUnit, expectedDimensionUnit) - XCTAssertEqual(viewModel.weightUnit, expectedWeightUnit) - } - @MainActor func test_it_with_not_all_field_values_set() { // Given let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -61,8 +38,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -79,8 +54,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -98,8 +71,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -117,8 +88,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -136,8 +105,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // Then @@ -150,8 +117,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -167,8 +132,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: mockStores) // When @@ -193,8 +156,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let mockStores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: dimensionUnit, - weightUnit: weightUnit, stores: mockStores) let length = "1" let width = "2" @@ -215,8 +176,8 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { switch packageDataResult { case .success(let packageData): XCTAssertNotNil(packageData) - XCTAssertEqual(packageData.dimensionsDescription, expectedDimensions) - XCTAssertEqual(packageData.weightDescription, expectedWeight) + XCTAssertEqual(packageData.dimensionsDescription(unit: dimensionUnit), expectedDimensions) + XCTAssertEqual(packageData.weightDescription(unit: weightUnit), expectedWeight) case .failure(let failure): XCTFail(failure.localizedDescription) } @@ -228,8 +189,6 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { let siteID: Int64 = 1234 let stores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingAddCustomPackageViewModel(siteID: siteID, - dimensionsUnit: "in", - weightUnit: "oz", stores: stores) let packageName = "a" stores.whenReceivingAction(ofType: WooShippingAction.self) { action in @@ -258,6 +217,26 @@ final class WooShippingAddCustomPackageViewModelTests: XCTestCase { XCTFail(failure.localizedDescription) } } + + func test_it_handles_selected_package_data() { + // Given + let selectedPackage = WooShippingPackageData(name: "", + length: "31.75", + width: "24.13", + height: "1.27", + weight: "", + source: .custom, + packageType: "envelope") + + // When + let viewModel = WooShippingAddCustomPackageViewModel(selectedPackage: selectedPackage) + + // Then + XCTAssertEqual(viewModel.fieldValues[.length], "31.75") + XCTAssertEqual(viewModel.fieldValues[.width], "24.13") + XCTAssertEqual(viewModel.fieldValues[.height], "1.27") + XCTAssertEqual(viewModel.packageType, .envelope) + } } extension WooShippingAddCustomPackageViewModel { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddPackageViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddPackageViewModelTests.swift new file mode 100644 index 00000000000..f5c57c60882 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingAddPackageViewModelTests.swift @@ -0,0 +1,365 @@ +import XCTest +import TestKit +@testable import WooCommerce +import Yosemite + +final class WooShippingAddPackageViewModelTests: XCTestCase { + @MainActor + func test_it_inits() { + // Given/When + let siteID: Int64 = 1234 + let mockStores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingAddPackageViewModel(siteID: siteID, + stores: mockStores) + + // Then + XCTAssertNotNil(viewModel) + + XCTAssertEqual(viewModel.isLoadingPackages, false) + XCTAssertEqual(viewModel.selectedSavedPackageId, nil) + XCTAssertEqual(viewModel.customSavedPackages.count, 0) + XCTAssertEqual(viewModel.predefinedSavedPackages.count, 0) + XCTAssertEqual(viewModel.hasSavedPackages, false) + XCTAssertNil(viewModel.selectedSavedPackage) + + XCTAssertEqual(viewModel.carrierPackages.count, 0) + XCTAssertEqual(viewModel.selectedCarriersTabIndex, nil) + XCTAssertEqual(viewModel.selectedCarriersPackageId, nil) + XCTAssertEqual(viewModel.starredCarriersPackages.count, 0) + XCTAssertEqual(viewModel.carrierTabs.count, 0) + XCTAssertNil(viewModel.selectedCarrierTab) + XCTAssertNil(viewModel.selectedCarriersPackage) + } + + @MainActor + func test_star_unstar_package() { + // Given + let siteID: Int64 = 1234 + let mockStores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingAddPackageViewModel(siteID: siteID, + stores: mockStores) + + // When + // Star + viewModel.starUnstarPackage("1", carrierID: "usps") + + // Then + XCTAssertEqual(viewModel.starredCarriersPackages.count, 1) + + // When + // Unstar + viewModel.starUnstarPackage("1", carrierID: "usps") + + // Then + XCTAssertEqual(viewModel.starredCarriersPackages.count, 0) + } + + @MainActor + func test_load_packages_dispatches_loadPackages_action() async { + // Given + let siteID: Int64 = 1234 + let mockStores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingAddPackageViewModel(siteID: siteID, + stores: mockStores) + + mockStores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case let .loadPackages(receivedSiteID, completion): + XCTAssertEqual(receivedSiteID, siteID) + completion(.success(.fake())) + default: + XCTFail("Received unexpected action: \(action)") + } + } + + // When + await viewModel.loadPackages() + + // Then + XCTAssertEqual(mockStores.receivedActions.count, 1) + assertThat(mockStores.receivedActions.first, isAnInstanceOf: WooShippingAction.self) + } + + @MainActor + func test_remove_saved_package_dispatches_deletePackage_action() { + // Given + let siteID: Int64 = 1234 + let mockStores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingAddPackageViewModel(siteID: siteID, + stores: mockStores) + + let customPackage = WooShippingCustomPackage.fake().copy(id: "custom") + let predefinedPackage = WooShippingPredefinedPackage(id: "predefined", + name: "name", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "") + let predefinedSavedPackage = WooShippingSavedPredefinedPackage(groupTitle: "group", + providerID: "usps", + package: predefinedPackage) + + mockStores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case let .deletePackage(receivedSiteID, packageID, completion): + XCTAssertEqual(receivedSiteID, siteID) + XCTAssert(packageID == customPackage.id || packageID == predefinedSavedPackage.id) + completion(.success(.fake())) + default: + XCTFail("Received unexpected action: \(action)") + } + } + + // When/Then + viewModel.removeSavedPackage(customPackage.toPackageData()) + XCTAssertEqual(mockStores.receivedActions.count, 1) + assertThat(mockStores.receivedActions.first, isAnInstanceOf: WooShippingAction.self) + + // When/Then + viewModel.removeSavedPackage(predefinedSavedPackage.toPackageData()) + XCTAssertEqual(mockStores.receivedActions.count, 2) + assertThat(mockStores.receivedActions.first, isAnInstanceOf: WooShippingAction.self) + } + + func test_it_fetches_and_transforms_packages_from_storage() throws { + // Given + let siteID: Int64 = 1 + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: [.fake().copy(id: "Custom Package")], + savedPredefinedPackages: [sampleSavedPredefinedPackage()], + allPredefinedOptions: [sampleCarrierPredefinedOptions()]) + let storageManager = MockStorageManager() + storageManager.insertSamplePackages(readOnlyPackages: packages) + + // When + let viewModel = WooShippingAddPackageViewModel(siteID: siteID, storage: storageManager) + + // Then + XCTAssertEqual(viewModel.customSavedPackages.count, 1) + XCTAssertEqual(viewModel.predefinedSavedPackages.count, 1) + XCTAssertEqual(viewModel.starredCarriersPackages.count, 1) + XCTAssertEqual(viewModel.carrierPackages.count, 1) + let carrierPackageGroups = try XCTUnwrap(viewModel.carrierPackages.first?.packageGroups) + XCTAssertEqual(carrierPackageGroups.count, 3) + } + + func test_it_handles_selected_custom_package() { + // Given + let siteID: Int64 = 1 + let selectedPackage = WooShippingPackageData(name: "", + length: "31.75", + width: "24.13", + height: "1.27", + weight: "", + source: .custom, + packageType: "envelope") + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: [.fake().copy(id: "Custom Envelope")], + savedPredefinedPackages: [sampleSavedPredefinedPackage()], + allPredefinedOptions: [sampleCarrierPredefinedOptions()]) + let storageManager = MockStorageManager() + storageManager.insertSamplePackages(readOnlyPackages: packages) + + // When + let viewModel = WooShippingAddPackageViewModel(selectedPackage: selectedPackage, siteID: siteID, storage: storageManager) + + // Then + XCTAssertEqual(viewModel.selectedPackageType, .custom) + XCTAssertNil(viewModel.selectedSavedPackage) + XCTAssertNil(viewModel.selectedCarriersPackage) + } + + func test_it_handles_selected_saved_custom_package() { + // Given + let siteID: Int64 = 1 + let selectedPackage = WooShippingPackageData(name: "Custom Envelope", + length: "31.75", + width: "24.13", + height: "1.27", + weight: "0", + source: .custom, + packageType: "envelope") + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: [.fake().copy(id: "Custom Envelope")], + savedPredefinedPackages: [sampleSavedPredefinedPackage()], + allPredefinedOptions: [sampleCarrierPredefinedOptions()]) + let storageManager = MockStorageManager() + storageManager.insertSamplePackages(readOnlyPackages: packages) + + // When + let viewModel = WooShippingAddPackageViewModel(selectedPackage: selectedPackage, siteID: siteID, storage: storageManager) + + // Then + XCTAssertEqual(viewModel.selectedPackageType, .saved) + XCTAssertNotNil(viewModel.selectedSavedPackage) + } + + func test_it_handles_selected_predefined_package() { + // Given + let siteID: Int64 = 1 + let selectedPackage = WooShippingPackageData(name: "large_flat_box", + length: "31.11", + width: "31.11", + height: "15.24", + weight: "0", + source: .predefined(sourceTitle: "Large Flat Rate Box", sourceID: "usps"), + packageType: "box") + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: [.fake().copy(id: "Custom Envelope")], + savedPredefinedPackages: [sampleSavedPredefinedPackage()], + allPredefinedOptions: [sampleCarrierPredefinedOptions()]) + let storageManager = MockStorageManager() + storageManager.insertSamplePackages(readOnlyPackages: packages) + + // When + let viewModel = WooShippingAddPackageViewModel(selectedPackage: selectedPackage, siteID: siteID, storage: storageManager) + + // Then + XCTAssertEqual(viewModel.selectedPackageType, .carrier) + XCTAssertNotNil(viewModel.selectedCarriersPackage) + } + + func test_it_handles_selected_saved_predefined_package() { + // Given + let siteID: Int64 = 1 + let selectedPackage = WooShippingPackageData(name: "small_flat_box", + length: "8.63", + width: "5.38", + height: "1.63", + weight: "0", + source: .predefined(sourceTitle: "Small Flat Rate Box", sourceID: "usps"), + packageType: "box") + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: [.fake().copy(id: "Custom Envelope")], + savedPredefinedPackages: [sampleSavedPredefinedPackage()], + allPredefinedOptions: [sampleCarrierPredefinedOptions()]) + let storageManager = MockStorageManager() + storageManager.insertSamplePackages(readOnlyPackages: packages) + + // When + let viewModel = WooShippingAddPackageViewModel(selectedPackage: selectedPackage, siteID: siteID, storage: storageManager) + + // Then + XCTAssertEqual(viewModel.selectedPackageType, .saved) + XCTAssertNotNil(viewModel.selectedSavedPackage) + } + + @MainActor + func test_it_keeps_order_after_reloads() async { + // Given + let siteID: Int64 = 1 + let customPackages: [WooShippingCustomPackage] = [ + .fake().copy(id: "Custom1"), + .fake().copy(id: "Custom2"), + .fake().copy(id: "Custom3"), + ] + let allPredefinedOptions = [sampleCarrierPredefinedOptions()] + let savedPredefinedPackages: [WooShippingSavedPredefinedPackage] = [ + .init(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "small_flat_box", + name: "Small Flat Rate Box", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "pri_flat_boxes")), + .init(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "large_flat_box", + name: "Large Flat Rate Box", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "pri_flat_boxes")), + .init(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "medium_flat_box_top", + name: "Medium Flat Rate Box", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "pri_flat_boxes")) + ] + let packages = WooShippingPackagesResponse(siteID: siteID, + customPackages: customPackages, + savedPredefinedPackages: savedPredefinedPackages, + allPredefinedOptions: allPredefinedOptions) + let storageManager = MockStorageManager() + + storageManager.insertSamplePackages(readOnlyPackages: packages) + + let mockStores = MockStoresManager(sessionManager: .testingInstance) + mockStores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case let .loadPackages(receivedSiteID, completion): + XCTAssertEqual(receivedSiteID, siteID) + completion(.success(packages)) + default: + XCTFail("Received unexpected action: \(action)") + } + } + + let viewModel = WooShippingAddPackageViewModel(selectedPackage: nil, siteID: siteID, stores: mockStores, storage: storageManager) + + // Do first load to get it sorted once + await viewModel.loadPackages() + let sortedPredefinedSavedPackages = viewModel.predefinedSavedPackages + let sortedCustomSavedPackages = viewModel.customSavedPackages + + for _ in 0..<5 { + // When + await viewModel.loadPackages() + // Then + // check order + XCTAssertEqual(sortedPredefinedSavedPackages.count, viewModel.predefinedSavedPackages.count) + for (index, package) in sortedPredefinedSavedPackages.enumerated() { + XCTAssertEqual(package.id, viewModel.predefinedSavedPackages[index].id) + } + XCTAssertEqual(sortedCustomSavedPackages.count, viewModel.customSavedPackages.count) + for (index, package) in sortedCustomSavedPackages.enumerated() { + XCTAssertEqual(package.id, viewModel.customSavedPackages[index].id) + } + } + } +} + +private extension WooShippingAddPackageViewModelTests { + func sampleSavedPredefinedPackage() -> WooShippingSavedPredefinedPackage { + WooShippingSavedPredefinedPackage(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "small_flat_box", + name: "Small Flat Rate Box", + isLetter: false, + dimensions: "8.63 x 5.38 x 1.63", + boxWeight: "", + groupId: "pri_flat_boxes")) + } + + func sampleCarrierPredefinedOptions() -> WooShippingCarrierPredefinedOptions { + WooShippingCarrierPredefinedOptions(carrierID: "usps", + predefinedOptions: [.init(title: "pri_flat_boxes", + providerID: "usps", + predefinedPackages: [.init(id: "small_flat_box", + name: "Small Flat Rate Box", + isLetter: false, + dimensions: "8.63 x 5.38 x 1.63", + boxWeight: "", + groupId: "pri_flat_boxes")]), + .init(title: "pri_flat_boxes", + providerID: "usps", + predefinedPackages: [.init(id: "large_flat_box", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "")]), + .init(title: "pri_flat_boxes", + providerID: "usps", + predefinedPackages: [.init(id: "medium_flat_box_top", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "")])]) + } +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift index 7fb4590ec45..fdc552a53f1 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModelTests.swift @@ -35,16 +35,50 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { XCTAssertEqual(viewModel.shippingRates.count, 1) } - func test_site_address_converted_to_formatted_originAddress() { + func test_origin_addresses_fetched_and_converted_to_originAddresses_view_model() { // Given - let siteSettings = mapLoadGeneralSiteSettingsResponse() - let siteAddress = SiteAddress(siteSettings: siteSettings) + let originAddress = WooShippingOriginAddress.fake().copy(id: "default", defaultAddress: true) + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + if case let .loadOriginAddresses(_, completion) = action { + completion(.success([originAddress])) + } + } + + // When + let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake(), stores: stores) + + // Then + XCTAssertEqual(viewModel.originAddresses.addresses.count, 1) + XCTAssertEqual(viewModel.originAddresses.selectedAddressID, originAddress.id) + } + + func test_default_origin_address_fetched_and_converted_to_formatted_originAddress() { + // Given + let originAddresses = [WooShippingOriginAddress.fake(), + WooShippingOriginAddress.fake().copy(address1: "123 Main Street", + city: "San Francisco", + state: "CA", + postcode: "12345", + country: "US", + defaultAddress: true)] + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case .loadOriginAddresses(_, let completion): + completion(.success(originAddresses)) + case .loadPackages: + break + default: + XCTFail("Unexpected action: \(action)") + } + } // When - let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake(), originAddress: siteAddress) + let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake(), stores: stores) // Then - XCTAssertEqual("60 29th Street #343, Auburn NY 13021, US", viewModel.originAddress) + XCTAssertEqual("123 Main Street, San Francisco CA 12345, US", viewModel.originAddress) } func test_order_shipping_address_converted_to_formatted_desinationAddressLines() { @@ -82,6 +116,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { switch action { case let .purchaseShippingLabel(_, _, _, _, _, _, _, _, completion): completion(.success(ShippingLabel.fake())) + case .loadPackages, .loadOriginAddresses: + break default: XCTFail("Unexpected action: \(action)") } @@ -90,8 +126,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { // When let markOrderComplete: Bool = waitFor { promise in let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), - originAddress: SiteAddress(siteSettings: self.mapLoadGeneralSiteSettingsResponse()), - selectedPackage: ShippingLabelPackageSelected.fake(), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: self.samplePackageData(), selectedRate: self.sampleSelectedRate(), stores: stores) { complete in promise(complete) @@ -111,6 +147,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { switch action { case let .purchaseShippingLabel(_, _, _, _, _, _, _, _, completion): completion(.success(ShippingLabel.fake())) + case .loadPackages, .loadOriginAddresses: + break default: XCTFail("Unexpected action: \(action)") } @@ -119,8 +157,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { // When let markOrderComplete: Bool = waitFor { promise in let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), - originAddress: SiteAddress(siteSettings: self.mapLoadGeneralSiteSettingsResponse()), - selectedPackage: ShippingLabelPackageSelected.fake(), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: self.samplePackageData(), selectedRate: self.sampleSelectedRate(), stores: stores) { complete in promise(complete) @@ -133,11 +171,11 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { XCTAssertTrue(markOrderComplete) } - func test_canPurchaseLabel_true_when_shipping_rate_is_selected() throws { + func test_isPurchaseButtonEnabled_true_when_required_fields_are_set() throws { // Given let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), - originAddress: SiteAddress(siteSettings: mapLoadGeneralSiteSettingsResponse()), - selectedPackage: ShippingLabelPackageSelected.fake(), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: samplePackageData(), selectedRate: sampleSelectedRate()) // Then @@ -198,6 +236,11 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { // Given let expectedShippingLabel = ShippingLabel.fake().copy(carrierID: "usps", trackingNumber: "1234567890") let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: samplePackageData(), + selectedRate: sampleSelectedRate(), + stores: stores) stores.whenReceivingAction(ofType: WooShippingAction.self) { action in switch action { case let .purchaseShippingLabel(_, _, _, _, _, _, _, _, completion): @@ -206,11 +249,6 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { XCTFail("Unexpected action: \(action)") } } - let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), - originAddress: SiteAddress(siteSettings: mapLoadGeneralSiteSettingsResponse()), - selectedPackage: ShippingLabelPackageSelected.fake(), - selectedRate: sampleSelectedRate(), - stores: stores) // When viewModel.purchaseLabel() @@ -226,8 +264,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { var isPurchasingLabelDuringPurchase = false let stores = MockStoresManager(sessionManager: .testingInstance) let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), - originAddress: SiteAddress(siteSettings: mapLoadGeneralSiteSettingsResponse()), - selectedPackage: ShippingLabelPackageSelected.fake(), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: samplePackageData(), selectedRate: sampleSelectedRate(), stores: stores) stores.whenReceivingAction(ofType: WooShippingAction.self) { action in @@ -235,6 +273,8 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { case let .purchaseShippingLabel(_, _, _, _, _, _, _, _, completion): isPurchasingLabelDuringPurchase = viewModel.isPurchasingLabel completion(.success(ShippingLabel.fake())) + case let .loadLabelRates(_, _, _, _, _, completion): + completion(.success([])) default: XCTFail("Unexpected action: \(action)") } @@ -253,30 +293,55 @@ final class WooShippingCreateLabelsViewModelTests: XCTestCase { func test_selectPackage_sets_selectedPackage_with_package_data() { // Given - let packageData = WooShippingPackageData(id: "small_flat_box", - name: "Small Flat Rate Box", - length: "21.91", - width: "13.65", - height: "4.13", - dimensionsUnit: "cm", - weight: "0", - weightUnit: "kg", - source: .predefined("usps"), - packageType: "box") let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake()) // When - viewModel.selectPackage(packageData) + viewModel.selectPackage(samplePackageData()) // Then XCTAssertNotNil(viewModel.selectedPackage) - XCTAssertEqual(viewModel.selectedPackage?.id, "shipment_0") - XCTAssertEqual(viewModel.selectedPackage?.boxID, "small_flat_box") - XCTAssertEqual(viewModel.selectedPackage?.length, 21.91) - XCTAssertEqual(viewModel.selectedPackage?.width, 13.65) - XCTAssertEqual(viewModel.selectedPackage?.height, 4.13) - XCTAssertEqual(viewModel.selectedPackage?.weight, 0) - XCTAssertEqual(viewModel.selectedPackage?.isLetter, false) + XCTAssertEqual(viewModel.selectedPackage?.id, samplePackageData().id) + } + + func test_selectPackage_sets_shipmentWeight_with_items_and_package_weight() { + // Given + let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake(), itemsDataSource: MockItemsDataSource()) + + // When + viewModel.selectPackage(samplePackageData()) + + // Then + XCTAssertEqual(viewModel.shipmentWeight, "1.25") + } + + func test_changing_shipmentWeight_loads_new_label_rates_with_updated_weight() { + // Given + let expectedWeight = 2.5 + let stores = MockStoresManager(sessionManager: .testingInstance) + let viewModel = WooShippingCreateLabelsViewModel(order: Order.fake().copy(shippingAddress: Address.fake()), + selectedOriginAddress: WooShippingOriginAddress.fake(), + selectedPackage: samplePackageData(), + stores: stores, + itemsDataSource: MockItemsDataSource(), + debounceDuration: 0) + + + // When + let packageWeightForLabelRates: Double? = waitFor { promise in + stores.whenReceivingAction(ofType: WooShippingAction.self) { action in + switch action { + case let .loadLabelRates(_, _, _, _, packages, _): + promise(packages.first?.weight) + default: + XCTFail("Unexpected action: \(action)") + } + } + + viewModel.shipmentWeight = expectedWeight.description + } + + // Then + XCTAssertEqual(packageWeightForLabelRates, expectedWeight) } } @@ -340,4 +405,27 @@ private extension WooShippingCreateLabelsViewModelTests { deliveryDays: 2, deliveryDateGuaranteed: false) : nil) } + + func samplePackageData() -> WooShippingPackageDataRepresentable { + WooShippingPackageData(id: "small_flat_box", + name: "Small Flat Rate Box", + length: "21.91", + width: "13.65", + height: "4.13", + weight: ".25", + source: .predefined(sourceTitle: "usps", sourceID: "usps"), + packageType: "box") + } +} + +private final class MockItemsDataSource: WooShippingItemsDataSource { + var items = [ShippingLabelPackageItem(productOrVariationID: 1, + name: "Shirt", + weight: 0.5, + quantity: 2, + value: 9.99, + dimensions: ProductDimensions.fake(), + attributes: [], + imageURL: nil)] + var currency = "GBP" } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingOriginAddressListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingOriginAddressListViewModelTests.swift new file mode 100644 index 00000000000..08fd19d5036 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Shipping Label/WooShipping Create Shipping Labels/WooShippingOriginAddressListViewModelTests.swift @@ -0,0 +1,77 @@ +import XCTest +@testable import WooCommerce +import Yosemite + +final class WooShippingOriginAddressListViewModelTests: XCTestCase { + + func test_it_inits_with_expected_addresses() { + // Given + let selectedAddressID = "1" + let addresses = [WooShippingOriginAddress.fake().copy(id: selectedAddressID), WooShippingOriginAddress.fake()] + + // When + let viewModel = WooShippingOriginAddressListViewModel(addresses: addresses, selectedAddressID: selectedAddressID) + + // Then + XCTAssertEqual(viewModel.addresses, addresses) + XCTAssertEqual(viewModel.selectedAddressID, selectedAddressID) + } + + func test_isSelected_returns_expected_value_for_selected_address() { + // Given + let selectedAddress = WooShippingOriginAddress.fake().copy(id: "1") + let addresses = [selectedAddress, WooShippingOriginAddress.fake().copy(id: "2")] + let viewModel = WooShippingOriginAddressListViewModel(addresses: addresses, selectedAddressID: selectedAddress.id) + + // When + let isSelected = viewModel.isSelected(selectedAddress) + + // Then + XCTAssertTrue(isSelected) + } + + func test_isSelected_returns_expected_value_for_unselected_address() { + // Given + let unselectedAddress = WooShippingOriginAddress.fake().copy(id: "1") + let addresses = [unselectedAddress, WooShippingOriginAddress.fake().copy(id: "2")] + let viewModel = WooShippingOriginAddressListViewModel(addresses: addresses, selectedAddressID: nil) + + // When + let isSelected = viewModel.isSelected(unselectedAddress) + + // Then + XCTAssertFalse(isSelected) + } + + func test_select_sets_selectedAddressID() { + // Given + let addressToSelect = WooShippingOriginAddress.fake().copy(id: "1") + let addresses = [addressToSelect, WooShippingOriginAddress.fake().copy(id: "2")] + let viewModel = WooShippingOriginAddressListViewModel(addresses: addresses, selectedAddressID: nil) + + // When + viewModel.select(addressToSelect) + + // Then + XCTAssertEqual(viewModel.selectedAddressID, addressToSelect.id) + } + + func test_select_calls_onSelect_closure() { + // Given + let addressToSelect = WooShippingOriginAddress.fake().copy(id: "1") + let addresses = [addressToSelect, WooShippingOriginAddress.fake().copy(id: "2")] + let viewModel = WooShippingOriginAddressListViewModel(addresses: addresses, selectedAddressID: nil) + + // When + let selectedAddress = waitFor { promise in + viewModel.onSelect = { address in + promise(address) + } + viewModel.select(addressToSelect) + } + + // Then + XCTAssertEqual(selectedAddress, addressToSelect) + } + +} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Themes/ThemesCarouselViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Themes/ThemesCarouselViewModelTests.swift index cbdbdf5125a..909e1cea770 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Themes/ThemesCarouselViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Themes/ThemesCarouselViewModelTests.swift @@ -103,7 +103,7 @@ final class ThemesCarouselViewModelTests: XCTestCase { viewModel.updateCurrentTheme(id: theme1.id) // Then - waitUntil { + await until { viewModel.state == .content(themes: [theme2]) } } @@ -132,7 +132,7 @@ final class ThemesCarouselViewModelTests: XCTestCase { viewModel.updateCurrentTheme(id: theme1.id) // Then - waitUntil { + await until { viewModel.state == .error } } diff --git a/WooCommerce/WooCommerceUITests/Tests/StatsTests.swift b/WooCommerce/WooCommerceUITests/Tests/DashboardTests.swift similarity index 87% rename from WooCommerce/WooCommerceUITests/Tests/StatsTests.swift rename to WooCommerce/WooCommerceUITests/Tests/DashboardTests.swift index bc14f597969..5724465e009 100644 --- a/WooCommerce/WooCommerceUITests/Tests/StatsTests.swift +++ b/WooCommerce/WooCommerceUITests/Tests/DashboardTests.swift @@ -1,7 +1,7 @@ import UITestsFoundation import XCTest -final class StatsTests: XCTestCase { +final class DashboardTests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false @@ -14,8 +14,7 @@ final class StatsTests: XCTestCase { try LoginFlow.login() } - /// TODO: Update tests after the new stats screen is released. - func skipped_test_load_stats_screen() throws { + func test_load_performance_card() throws { try TabNavComponent().goToMyStoreScreen() .verifyTodayStatsLoaded() .goToThisWeekTab() @@ -26,8 +25,7 @@ final class StatsTests: XCTestCase { .verifyThisYearStatsLoaded() } - /// TODO: Update tests after the new stats screen is released. - func skipped_test_view_detailed_chart_stats() throws { + func test_view_detailed_chart_performance_card() throws { let myStoreScreen = try MyStoreScreen() var dailyRevenue = try TabNavComponent() diff --git a/WooFoundation/WooFoundation/Currency/CurrencySettings.swift b/WooFoundation/WooFoundation/Currency/CurrencySettings.swift index ec10484c7a4..a1770fe739c 100644 --- a/WooFoundation/WooFoundation/Currency/CurrencySettings.swift +++ b/WooFoundation/WooFoundation/Currency/CurrencySettings.swift @@ -34,6 +34,9 @@ public class CurrencySettings: Codable, Equatable { public var groupingSeparator: String public var decimalSeparator: String public var fractionDigits: Int + public var currencySymbol: String { + symbol(from: currencyCode) + } // MARK: - Initializers & Methods diff --git a/Yosemite/Yosemite.xcodeproj/project.pbxproj b/Yosemite/Yosemite.xcodeproj/project.pbxproj index d790f70b3d1..d013906f66b 100644 --- a/Yosemite/Yosemite.xcodeproj/project.pbxproj +++ b/Yosemite/Yosemite.xcodeproj/project.pbxproj @@ -61,12 +61,14 @@ 026CF62C237D92DC009563D4 /* ProductVariationAttribute+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026CF62B237D92DC009563D4 /* ProductVariationAttribute+ReadOnlyConvertible.swift */; }; 026D52C0238235930092AE05 /* ProductVariationStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026D52BF238235930092AE05 /* ProductVariationStoreTests.swift */; }; 0271E1662509CF0100633F7A /* AnyError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0271E1652509CF0100633F7A /* AnyError.swift */; }; + 027ADB6C2D1BF3AD009608DB /* POSVariableParentProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027ADB6B2D1BF3AD009608DB /* POSVariableParentProduct.swift */; }; 027CC11129F7AAEA00614B6E /* MockGenerativeContentRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027CC11029F7AAEA00614B6E /* MockGenerativeContentRemote.swift */; }; 0286A1B82A0CBDC40099EF94 /* FeatureFlagStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286A1B72A0CBDC40099EF94 /* FeatureFlagStore.swift */; }; 0286A1BA2A0CBE1B0099EF94 /* FeatureFlagAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286A1B92A0CBE1B0099EF94 /* FeatureFlagAction.swift */; }; 0286A1BC2A0CC4120099EF94 /* FeatureFlagStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286A1BB2A0CC4120099EF94 /* FeatureFlagStoreTests.swift */; }; 0286A1BE2A0CC4810099EF94 /* MockFeatureFlagRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0286A1BD2A0CC4810099EF94 /* MockFeatureFlagRemote.swift */; }; 028BCE2422DE22BB00056966 /* SiteVisitStatsStoreErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028BCE2322DE22BB00056966 /* SiteVisitStatsStoreErrorTests.swift */; }; + 029149762D2663AB00F7B3B3 /* POSVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029149752D2663AB00F7B3B3 /* POSVariation.swift */; }; 029249E8274B8AEE002E9C34 /* MockMediaRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029249E7274B8AEE002E9C34 /* MockMediaRemote.swift */; }; 029B00A7230D64E800B0AE66 /* StatsTimeRangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029B00A6230D64E800B0AE66 /* StatsTimeRangeTests.swift */; }; 029BA557255E0CD4006171FD /* ShippingLabelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029BA556255E0CD4006171FD /* ShippingLabelStore.swift */; }; @@ -79,6 +81,8 @@ 02C254FA2563B66600A04423 /* ShippingLabelRefund+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C254F92563B66600A04423 /* ShippingLabelRefund+ReadOnlyConvertible.swift */; }; 02C254FE2563C6E500A04423 /* ShippingLabelSettings+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C254FD2563C6E500A04423 /* ShippingLabelSettings+ReadOnlyConvertible.swift */; }; 02C255022563C76A00A04423 /* ShippingLabel+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C255012563C76A00A04423 /* ShippingLabel+ReadOnlyConvertible.swift */; }; + 02CC7C2C2D2CE5CB00907B83 /* ProductVariationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CC7C2B2D2CE5CB00907B83 /* ProductVariationFormatter.swift */; }; + 02CC7C2E2D2CE5F600907B83 /* VariationAttributeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CC7C2D2D2CE5F600907B83 /* VariationAttributeViewModel.swift */; }; 02DAE7F8291A9F11009342B7 /* DomainStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DAE7F7291A9F11009342B7 /* DomainStoreTests.swift */; }; 02DAE7FA291A9F36009342B7 /* MockDomainRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DAE7F9291A9F36009342B7 /* MockDomainRemote.swift */; }; 02DF98092A136BFB0009E2EA /* MockSitePluginsRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DF98082A136BFB0009E2EA /* MockSitePluginsRemote.swift */; }; @@ -259,15 +263,18 @@ 578CE7942475F52F00492EBF /* MockNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578CE7932475F52F00492EBF /* MockNote.swift */; }; 578CE7972475FD8200492EBF /* MockProductReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 578CE7962475FD8200492EBF /* MockProductReview.swift */; }; 57DFCC7925003C4000251E0C /* FetchResultSnapshotsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57DFCC7825003C4000251E0C /* FetchResultSnapshotsProvider.swift */; }; + 6801E41A2D1002CE00F9DF46 /* POSReceiptServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6801E4192D1002CC00F9DF46 /* POSReceiptServiceTests.swift */; }; + 6801E41C2D1007D200F9DF46 /* MockPOSReceiptsRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6801E41B2D1007D000F9DF46 /* MockPOSReceiptsRemote.swift */; }; 681D952B28E0F62B00C4039E /* CustomerAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 681D952A28E0F62B00C4039E /* CustomerAction.swift */; }; - 687F83722C0EBF8900460AB3 /* POSProductProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687F83712C0EBF8900460AB3 /* POSProductProviderTests.swift */; }; + 687F83722C0EBF8900460AB3 /* PointOfSaleItemServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687F83712C0EBF8900460AB3 /* PointOfSaleItemServiceTests.swift */; }; 6889089F28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */; }; 6898F3742C0842150039F10A /* PointOfSaleItemServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6898F3732C0842150039F10A /* PointOfSaleItemServiceProtocol.swift */; }; 689D11D52891B9A400F6A83F /* WooFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 689D11D42891B9A400F6A83F /* WooFoundation.framework */; }; + 68A70DD22D0BF6F60013B807 /* POSReceiptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A70DD12D0BF6F30013B807 /* POSReceiptService.swift */; }; 68BD37B528DB2E9800C2A517 /* CustomerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BD37B428DB2E9800C2A517 /* CustomerStore.swift */; }; 68BD37B928DB323D00C2A517 /* CustomerStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BD37B828DB323D00C2A517 /* CustomerStoreTests.swift */; }; - 68EA25342C08734900C49AE2 /* POSProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68EA25332C08734800C49AE2 /* POSProduct.swift */; }; - 68EA25382C0876DF00C49AE2 /* PointOfSaleProductService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68EA25372C0876DF00C49AE2 /* PointOfSaleProductService.swift */; }; + 68EA25342C08734900C49AE2 /* POSSimpleProduct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68EA25332C08734800C49AE2 /* POSSimpleProduct.swift */; }; + 68EA25382C0876DF00C49AE2 /* PointOfSaleItemService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68EA25372C0876DF00C49AE2 /* PointOfSaleItemService.swift */; }; 741F34802195EA62005F5BD9 /* CommentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741F347F2195EA62005F5BD9 /* CommentAction.swift */; }; 741F34822195EA71005F5BD9 /* CommentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741F34812195EA71005F5BD9 /* CommentStore.swift */; }; 741F34842195F752005F5BD9 /* CommentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741F34832195F752005F5BD9 /* CommentStoreTests.swift */; }; @@ -386,6 +393,12 @@ CCE5F39D29F0165900087332 /* SubscriptionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE5F39C29F0165900087332 /* SubscriptionStore.swift */; }; CE01014F2368C41600783459 /* Refund+ReadOnlyType.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE01014E2368C41600783459 /* Refund+ReadOnlyType.swift */; }; CE0DB6C0233EB3F300A27E7A /* OrderRefundCondensed+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0DB6BF233EB3F300A27E7A /* OrderRefundCondensed+ReadOnlyConvertible.swift */; }; + CE0FBB1D2D0C5DE3008B7789 /* WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB1C2D0C5DD7008B7789 /* WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift */; }; + CE0FBB1F2D0C65A3008B7789 /* WooShippingPredefinedOption+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB1E2D0C6598008B7789 /* WooShippingPredefinedOption+ReadOnlyConvertible.swift */; }; + CE0FBB212D0C65B9008B7789 /* WooShippingPredefinedPackage+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB202D0C65B1008B7789 /* WooShippingPredefinedPackage+ReadOnlyConvertible.swift */; }; + CE0FBB232D0C65C8008B7789 /* WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB222D0C65C0008B7789 /* WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift */; }; + CE0FBB252D0C65EB008B7789 /* WooShippingCustomPackage+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB242D0C65DF008B7789 /* WooShippingCustomPackage+ReadOnlyConvertible.swift */; }; + CE0FBB2B2D0CAFCB008B7789 /* WooShippingPackagesResponse+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0FBB2A2D0CAFC7008B7789 /* WooShippingPackagesResponse+ReadOnlyConvertible.swift */; }; CE12FBDB221F406100C59248 /* OrderStatus+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE12FBDA221F406100C59248 /* OrderStatus+ReadOnlyConvertible.swift */; }; CE179D55235F4E1700C24EB3 /* RefundAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE179D54235F4E1700C24EB3 /* RefundAction.swift */; }; CE179D57235F4E7500C24EB3 /* RefundStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE179D56235F4E7500C24EB3 /* RefundStore.swift */; }; @@ -464,8 +477,11 @@ DE3C5B1D286AEDA10049E6AA /* MockOrderCardPresentPaymentEligibilityActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3C5B1C286AEDA10049E6AA /* MockOrderCardPresentPaymentEligibilityActionHandler.swift */; }; DE3C5B21286BF2270049E6AA /* MockSystemStatusActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3C5B20286BF2270049E6AA /* MockSystemStatusActionHandler.swift */; }; DE3C5B23286C03F90049E6AA /* MockCardPresentPaymentActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3C5B22286C03F80049E6AA /* MockCardPresentPaymentActionHandler.swift */; }; + DE423F3A2D0AD08600116F1B /* ReadOnlyConvertibleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE423F392D0AD07C00116F1B /* ReadOnlyConvertibleTests.swift */; }; DE50296728C7114800551736 /* JetpackConnectionStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE50296628C7114800551736 /* JetpackConnectionStoreTests.swift */; }; DE6831DD26445B2B00E88B9E /* SitePluginStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6831DC26445B2B00E88B9E /* SitePluginStoreTests.swift */; }; + DE87F4042D2BC93100869522 /* OrderFilterHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE87F4032D2BC92600869522 /* OrderFilterHistory.swift */; }; + DE87F4062D2BD49700869522 /* AppSettingsStoreTests+OrderFilterHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE87F4052D2BD49700869522 /* AppSettingsStoreTests+OrderFilterHistory.swift */; }; DEA493922B3BD49700EED015 /* BlazeTargetDevice+ReadOnlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA493912B3BD49700EED015 /* BlazeTargetDevice+ReadOnlyConvertible.swift */; }; DEA493942B3D588500EED015 /* BlazeTargetLanguage+ReadonlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA493932B3D588500EED015 /* BlazeTargetLanguage+ReadonlyConvertible.swift */; }; DEA493962B3D608B00EED015 /* BlazeTargetTopic+ReadonlyConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEA493952B3D608B00EED015 /* BlazeTargetTopic+ReadonlyConvertible.swift */; }; @@ -586,12 +602,14 @@ 026CF62B237D92DC009563D4 /* ProductVariationAttribute+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductVariationAttribute+ReadOnlyConvertible.swift"; sourceTree = ""; }; 026D52BF238235930092AE05 /* ProductVariationStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationStoreTests.swift; sourceTree = ""; }; 0271E1652509CF0100633F7A /* AnyError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyError.swift; sourceTree = ""; }; + 027ADB6B2D1BF3AD009608DB /* POSVariableParentProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSVariableParentProduct.swift; sourceTree = ""; }; 027CC11029F7AAEA00614B6E /* MockGenerativeContentRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGenerativeContentRemote.swift; sourceTree = ""; }; 0286A1B72A0CBDC40099EF94 /* FeatureFlagStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagStore.swift; sourceTree = ""; }; 0286A1B92A0CBE1B0099EF94 /* FeatureFlagAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagAction.swift; sourceTree = ""; }; 0286A1BB2A0CC4120099EF94 /* FeatureFlagStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagStoreTests.swift; sourceTree = ""; }; 0286A1BD2A0CC4810099EF94 /* MockFeatureFlagRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockFeatureFlagRemote.swift; sourceTree = ""; }; 028BCE2322DE22BB00056966 /* SiteVisitStatsStoreErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVisitStatsStoreErrorTests.swift; sourceTree = ""; }; + 029149752D2663AB00F7B3B3 /* POSVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSVariation.swift; sourceTree = ""; }; 029249E7274B8AEE002E9C34 /* MockMediaRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaRemote.swift; sourceTree = ""; }; 029B00A6230D64E800B0AE66 /* StatsTimeRangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTimeRangeTests.swift; sourceTree = ""; }; 029BA556255E0CD4006171FD /* ShippingLabelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelStore.swift; sourceTree = ""; }; @@ -604,6 +622,8 @@ 02C254F92563B66600A04423 /* ShippingLabelRefund+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShippingLabelRefund+ReadOnlyConvertible.swift"; sourceTree = ""; }; 02C254FD2563C6E500A04423 /* ShippingLabelSettings+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShippingLabelSettings+ReadOnlyConvertible.swift"; sourceTree = ""; }; 02C255012563C76A00A04423 /* ShippingLabel+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShippingLabel+ReadOnlyConvertible.swift"; sourceTree = ""; }; + 02CC7C2B2D2CE5CB00907B83 /* ProductVariationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationFormatter.swift; sourceTree = ""; }; + 02CC7C2D2D2CE5F600907B83 /* VariationAttributeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariationAttributeViewModel.swift; sourceTree = ""; }; 02DAE7F7291A9F11009342B7 /* DomainStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainStoreTests.swift; sourceTree = ""; }; 02DAE7F9291A9F36009342B7 /* MockDomainRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDomainRemote.swift; sourceTree = ""; }; 02DF98082A136BFB0009E2EA /* MockSitePluginsRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSitePluginsRemote.swift; sourceTree = ""; }; @@ -780,15 +800,18 @@ 578CE7962475FD8200492EBF /* MockProductReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProductReview.swift; sourceTree = ""; }; 57DFCC7825003C4000251E0C /* FetchResultSnapshotsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchResultSnapshotsProvider.swift; sourceTree = ""; }; 585B973F61632665297738A3 /* Pods-Yosemite.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Yosemite.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-Yosemite/Pods-Yosemite.release-alpha.xcconfig"; sourceTree = ""; }; + 6801E4192D1002CC00F9DF46 /* POSReceiptServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptServiceTests.swift; sourceTree = ""; }; + 6801E41B2D1007D000F9DF46 /* MockPOSReceiptsRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSReceiptsRemote.swift; sourceTree = ""; }; 681D952A28E0F62B00C4039E /* CustomerAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerAction.swift; sourceTree = ""; }; - 687F83712C0EBF8900460AB3 /* POSProductProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProductProviderTests.swift; sourceTree = ""; }; + 687F83712C0EBF8900460AB3 /* PointOfSaleItemServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemServiceTests.swift; sourceTree = ""; }; 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Customer+ReadOnlyConvertible.swift"; sourceTree = ""; }; 6898F3732C0842150039F10A /* PointOfSaleItemServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemServiceProtocol.swift; sourceTree = ""; }; 689D11D42891B9A400F6A83F /* WooFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WooFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 68A70DD12D0BF6F30013B807 /* POSReceiptService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSReceiptService.swift; sourceTree = ""; }; 68BD37B428DB2E9800C2A517 /* CustomerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerStore.swift; sourceTree = ""; }; 68BD37B828DB323D00C2A517 /* CustomerStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerStoreTests.swift; sourceTree = ""; }; - 68EA25332C08734800C49AE2 /* POSProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSProduct.swift; sourceTree = ""; }; - 68EA25372C0876DF00C49AE2 /* PointOfSaleProductService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleProductService.swift; sourceTree = ""; }; + 68EA25332C08734800C49AE2 /* POSSimpleProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSSimpleProduct.swift; sourceTree = ""; }; + 68EA25372C0876DF00C49AE2 /* PointOfSaleItemService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemService.swift; sourceTree = ""; }; 741F347F2195EA62005F5BD9 /* CommentAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentAction.swift; sourceTree = ""; }; 741F34812195EA71005F5BD9 /* CommentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentStore.swift; sourceTree = ""; }; 741F34832195F752005F5BD9 /* CommentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentStoreTests.swift; sourceTree = ""; }; @@ -916,6 +939,12 @@ CCE5F39C29F0165900087332 /* SubscriptionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStore.swift; sourceTree = ""; }; CE01014E2368C41600783459 /* Refund+ReadOnlyType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Refund+ReadOnlyType.swift"; sourceTree = ""; }; CE0DB6BF233EB3F300A27E7A /* OrderRefundCondensed+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderRefundCondensed+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB1C2D0C5DD7008B7789 /* WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB1E2D0C6598008B7789 /* WooShippingPredefinedOption+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedOption+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB202D0C65B1008B7789 /* WooShippingPredefinedPackage+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPredefinedPackage+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB222D0C65C0008B7789 /* WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB242D0C65DF008B7789 /* WooShippingCustomPackage+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingCustomPackage+ReadOnlyConvertible.swift"; sourceTree = ""; }; + CE0FBB2A2D0CAFC7008B7789 /* WooShippingPackagesResponse+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooShippingPackagesResponse+ReadOnlyConvertible.swift"; sourceTree = ""; }; CE12FBDA221F406100C59248 /* OrderStatus+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OrderStatus+ReadOnlyConvertible.swift"; sourceTree = ""; }; CE179D54235F4E1700C24EB3 /* RefundAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundAction.swift; sourceTree = ""; }; CE179D56235F4E7500C24EB3 /* RefundStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefundStore.swift; sourceTree = ""; }; @@ -994,8 +1023,11 @@ DE3C5B1C286AEDA10049E6AA /* MockOrderCardPresentPaymentEligibilityActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockOrderCardPresentPaymentEligibilityActionHandler.swift; sourceTree = ""; }; DE3C5B20286BF2270049E6AA /* MockSystemStatusActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSystemStatusActionHandler.swift; sourceTree = ""; }; DE3C5B22286C03F80049E6AA /* MockCardPresentPaymentActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCardPresentPaymentActionHandler.swift; sourceTree = ""; }; + DE423F392D0AD07C00116F1B /* ReadOnlyConvertibleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadOnlyConvertibleTests.swift; sourceTree = ""; }; DE50296628C7114800551736 /* JetpackConnectionStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackConnectionStoreTests.swift; sourceTree = ""; }; DE6831DC26445B2B00E88B9E /* SitePluginStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePluginStoreTests.swift; sourceTree = ""; }; + DE87F4032D2BC92600869522 /* OrderFilterHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderFilterHistory.swift; sourceTree = ""; }; + DE87F4052D2BD49700869522 /* AppSettingsStoreTests+OrderFilterHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppSettingsStoreTests+OrderFilterHistory.swift"; sourceTree = ""; }; DEA493912B3BD49700EED015 /* BlazeTargetDevice+ReadOnlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlazeTargetDevice+ReadOnlyConvertible.swift"; sourceTree = ""; }; DEA493932B3D588500EED015 /* BlazeTargetLanguage+ReadonlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlazeTargetLanguage+ReadonlyConvertible.swift"; sourceTree = ""; }; DEA493952B3D608B00EED015 /* BlazeTargetTopic+ReadonlyConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlazeTargetTopic+ReadonlyConvertible.swift"; sourceTree = ""; }; @@ -1135,6 +1167,7 @@ 0225512322FC310E00D98613 /* Extensions */ = { isa = PBXGroup; children = ( + DE423F392D0AD07C00116F1B /* ReadOnlyConvertibleTests.swift */, 0225512422FC312400D98613 /* OrderStatsV4Interval+DateTests.swift */, D8652E312630741000350F37 /* PaymentIntent+ReceiptParametersTests.swift */, FEEB2F5E268A1C5E0075A6E0 /* User+RolesTests.swift */, @@ -1269,6 +1302,7 @@ isa = PBXGroup; children = ( 20F616452CF4B4DE00F9FA2A /* POSOrderServiceTests.swift */, + 6801E4192D1002CC00F9DF46 /* POSReceiptServiceTests.swift */, 207D2D192CFA06BD00F79204 /* POSCartItemTests.swift */, ); path = POS; @@ -1354,6 +1388,7 @@ children = ( 4591A6AF274BB23000F51DCD /* OrderDateRangeFilter.swift */, 4591A6B3274BB29000F51DCD /* StoredOrderSettings.swift */, + DE87F4032D2BC92600869522 /* OrderFilterHistory.swift */, EE9D030D2B88550F0077CED1 /* FilterOrdersByProduct.swift */, 86BB4C952B89FCF30096E92D /* CustomerFilter.swift */, 016EFCAD2C4155650016BDAA /* OrderItem+BasePrice.swift */, @@ -1484,7 +1519,7 @@ 687F83702C0EBF3E00460AB3 /* PointOfSale */ = { isa = PBXGroup; children = ( - 687F83712C0EBF8900460AB3 /* POSProductProviderTests.swift */, + 687F83712C0EBF8900460AB3 /* PointOfSaleItemServiceTests.swift */, ); path = PointOfSale; sourceTree = ""; @@ -1493,8 +1528,10 @@ isa = PBXGroup; children = ( 6898F3732C0842150039F10A /* PointOfSaleItemServiceProtocol.swift */, - 68EA25332C08734800C49AE2 /* POSProduct.swift */, - 68EA25372C0876DF00C49AE2 /* PointOfSaleProductService.swift */, + 68EA25332C08734800C49AE2 /* POSSimpleProduct.swift */, + 027ADB6B2D1BF3AD009608DB /* POSVariableParentProduct.swift */, + 68EA25372C0876DF00C49AE2 /* PointOfSaleItemService.swift */, + 029149752D2663AB00F7B3B3 /* POSVariation.swift */, ); path = PointOfSale; sourceTree = ""; @@ -1595,6 +1632,12 @@ 031C1EAF27B1879C00298699 /* WCPayCardPaymentDetails+ReadOnlyConvertible.swift */, 031C1EAD27B1877000298699 /* WCPayCardPresentPaymentDetails+ReadOnlyConvertible.swift */, 6889089E28F7B8540081A07E /* Customer+ReadOnlyConvertible.swift */, + CE0FBB1C2D0C5DD7008B7789 /* WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift */, + CE0FBB1E2D0C6598008B7789 /* WooShippingPredefinedOption+ReadOnlyConvertible.swift */, + CE0FBB202D0C65B1008B7789 /* WooShippingPredefinedPackage+ReadOnlyConvertible.swift */, + CE0FBB222D0C65C0008B7789 /* WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift */, + CE0FBB242D0C65DF008B7789 /* WooShippingCustomPackage+ReadOnlyConvertible.swift */, + CE0FBB2A2D0CAFC7008B7789 /* WooShippingPackagesResponse+ReadOnlyConvertible.swift */, ); path = Storage; sourceTree = ""; @@ -1740,6 +1783,7 @@ 0202B6982387B01500F3EBE0 /* AppSettingsStoreTests+ProductsFeatureSwitch.swift */, 312DB64C268BD22B00AA0EE9 /* AppSettingsStoreTests+CardReaderSettings.swift */, 455A931C2747C19300259C7F /* AppSettingsStoreTests+OrdersSettings.swift */, + DE87F4052D2BD49700869522 /* AppSettingsStoreTests+OrderFilterHistory.swift */, 458C6DE725ACC554009B300D /* AppSettingsStoreTests+ProductsSettings.swift */, B5BC736720D1AA8F00B5B6FA /* AccountStoreTests.swift */, D88303E825E459DC00C877F9 /* CardPresentPaymentStoreTests.swift */, @@ -1922,6 +1966,7 @@ isa = PBXGroup; children = ( 20CF75B72CF4C3AD00ACCF4A /* MockPOSOrdersRemote.swift */, + 6801E41B2D1007D000F9DF46 /* MockPOSReceiptsRemote.swift */, 207D2D1C2CFA0CEF00F79204 /* MockPOSOrderableItem.swift */, D88303EE25E45E5500C877F9 /* CardPresentPayments */, 578CE7892475E78C00492EBF /* Networking */, @@ -2030,6 +2075,8 @@ isa = PBXGroup; children = ( B9C0C1072A3C666A00DF84EA /* ProductVariationStorageManager.swift */, + 02CC7C2B2D2CE5CB00907B83 /* ProductVariationFormatter.swift */, + 02CC7C2D2D2CE5F600907B83 /* VariationAttributeViewModel.swift */, ); path = ProductVariations; sourceTree = ""; @@ -2062,6 +2109,7 @@ isa = PBXGroup; children = ( DA3F0E1B2C218EF100B8C2F8 /* POSOrderService.swift */, + 68A70DD12D0BF6F30013B807 /* POSReceiptService.swift */, ); path = POS; sourceTree = ""; @@ -2282,6 +2330,7 @@ E138D4FA269EE5BD006EA5C6 /* CardPresentPaymentsOnboardingState.swift in Sources */, 02E3B625290267F2007E0F13 /* AccountCreationAction.swift in Sources */, 02C254FA2563B66600A04423 /* ShippingLabelRefund+ReadOnlyConvertible.swift in Sources */, + CE0FBB2B2D0CAFCB008B7789 /* WooShippingPackagesResponse+ReadOnlyConvertible.swift in Sources */, CECE6BBE2BA9DE3200A57C1F /* WCAnalyticsCustomer+ReadOnlyConvertible.swift in Sources */, 02FF054F23D983F30058E6E7 /* FileManager+URL.swift in Sources */, 03F3AFE728097D6400E328BE /* CardPresentPaymentsPlugin.swift in Sources */, @@ -2343,8 +2392,10 @@ 0218B4EE242E08B20083A847 /* MediaType.swift in Sources */, 247CE7C42582DE5300F9D9D1 /* ProductStockStatus+Mocks.swift in Sources */, DE3404FC28BC5E7800CF0D97 /* JetpackConnectionAction.swift in Sources */, + CE0FBB252D0C65EB008B7789 /* WooShippingCustomPackage+ReadOnlyConvertible.swift in Sources */, CE3B7AD72225ECA90050FE4B /* OrderStatusStore.swift in Sources */, B5631ECD2114DF8C008D3535 /* EntityListener.swift in Sources */, + 02CC7C2E2D2CE5F600907B83 /* VariationAttributeViewModel.swift in Sources */, D80F758A223F72AA002F4A3B /* ShipmentTrackingProviderGroup+ReadOnlyConvertible.swift in Sources */, B9AECD442851D95600E78584 /* TotalRefundedCalculationUseCase.swift in Sources */, 86DAA7982CD9E31E002AE55E /* MockPaymentActionHandler.swift in Sources */, @@ -2394,6 +2445,7 @@ B9C0C1082A3C666A00DF84EA /* ProductVariationStorageManager.swift in Sources */, CE01014F2368C41600783459 /* Refund+ReadOnlyType.swift in Sources */, 24163BA8257F41C500F94EC3 /* SessionManagerProtocol.swift in Sources */, + CE0FBB1F2D0C65A3008B7789 /* WooShippingPredefinedOption+ReadOnlyConvertible.swift in Sources */, D83B093925DECFD900B21F45 /* CardPresentPaymentStore.swift in Sources */, DEFD6D9326443A4000E51E0D /* SitePluginAction.swift in Sources */, 0271E1662509CF0100633F7A /* AnyError.swift in Sources */, @@ -2439,7 +2491,7 @@ B505254C20EE6491008090F5 /* Site+ReadOnlyConvertible.swift in Sources */, 26E5A07E25A6640E000DF8F6 /* ProductAttributeTermAction.swift in Sources */, 45ED6092257E72F4007B4AC6 /* ProductAttributeAction.swift in Sources */, - 68EA25342C08734900C49AE2 /* POSProduct.swift in Sources */, + 68EA25342C08734900C49AE2 /* POSSimpleProduct.swift in Sources */, B52E002B2119E64800700FDE /* ManagedObjectsDidChangeNotification.swift in Sources */, 93E7507A226E2D6C00BAF88A /* AccountSettings+ReadOnlyConvertible.swift in Sources */, 749374FE2249601F007D85D1 /* ProductStore.swift in Sources */, @@ -2461,6 +2513,7 @@ CC24A4F129ED4CEB0009D6DA /* ProductSubscription+ReadOnlyConvertible.swift in Sources */, 03101F0129DD7D9D00769CF3 /* CardReaderType+ReadOnlyConvertible.swift in Sources */, CE4FD4502350F27C00A16B31 /* OrderItemTax+ReadOnlyConvertible.swift in Sources */, + DE87F4042D2BC93100869522 /* OrderFilterHistory.swift in Sources */, 02FF055123D983F30058E6E7 /* MediaAssetExporter.swift in Sources */, 073AF84427DB10270074EDF0 /* CardPresentPaymentGatewayExtension.swift in Sources */, B9AECD3C2850F3C600E78584 /* OrderCardPresentPaymentEligibilityStore.swift in Sources */, @@ -2487,6 +2540,7 @@ 74A7689020D45F9300F9D437 /* OrderAction.swift in Sources */, D88E234525AE0EB90023F3B1 /* OrderFeeLine+ReadOnlyConvertible.swift in Sources */, 2665034D2620E0A90079A159 /* ProductAddOnOption+ReadOnlyConvertible.swift in Sources */, + 029149762D2663AB00F7B3B3 /* POSVariation.swift in Sources */, DED91DF02AD6756600CDCC53 /* BlazeStore.swift in Sources */, EE9D030E2B88550F0077CED1 /* FilterOrdersByProduct.swift in Sources */, 02FF055323D983F30058E6E7 /* URL+Media.swift in Sources */, @@ -2497,12 +2551,13 @@ DE2E8EA72953F85C002E4B14 /* WordPressSiteStore.swift in Sources */, 74858DB421C02B5A00754F3E /* OrderNote+ReadOnlyType.swift in Sources */, 0248B3652459018100A271A4 /* ResultsController+FilterProducts.swift in Sources */, - 68EA25382C0876DF00C49AE2 /* PointOfSaleProductService.swift in Sources */, + 68EA25382C0876DF00C49AE2 /* PointOfSaleItemService.swift in Sources */, 02E262C2238CF74D00B79588 /* StorageShippingSettingsService.swift in Sources */, FE28F6EE268440B1004465C7 /* UserAction.swift in Sources */, DE34050828BC706B00CF0D97 /* DeauthenticatedStore.swift in Sources */, 749375042249691D007D85D1 /* Product+ReadOnlyConvertible.swift in Sources */, D4CBAE6426D4464500BBE6D1 /* AnnouncementsAction.swift in Sources */, + CE0FBB212D0C65B9008B7789 /* WooShippingPredefinedPackage+ReadOnlyConvertible.swift in Sources */, EEB4E2C929B0489800371C3C /* StoreOnboardingTasksStore.swift in Sources */, CE43A90222A072D800A4FF29 /* ProductDownload+ReadOnlyConvertible.swift in Sources */, CEC7D5972CDE35AD00111B79 /* WooShippingAction.swift in Sources */, @@ -2526,6 +2581,7 @@ DEB387822C2AB4370025256E /* GoogleAdsStore.swift in Sources */, 3147030C2670333200EF253A /* WCPayAccount+PaymentGatewayAccount.swift in Sources */, 02EF1668292DB68C00D90AD6 /* PaymentAction.swift in Sources */, + CE0FBB232D0C65C8008B7789 /* WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift in Sources */, B5C9DE162087FF0E006B910A /* Store.swift in Sources */, 02FF056123D98FD40058E6E7 /* ImageSourceWriter.swift in Sources */, 0212AC5E242C67FA00C51F6C /* ProductsSortOrder.swift in Sources */, @@ -2551,20 +2607,24 @@ CE4FD4542350FC0100A16B31 /* OrderItemRefund+ReadOnlyConvertible.swift in Sources */, DE3C5B21286BF2270049E6AA /* MockSystemStatusActionHandler.swift in Sources */, 247CE7D02582E1C100F9D9D1 /* MockSessionManager.swift in Sources */, + 68A70DD22D0BF6F60013B807 /* POSReceiptService.swift in Sources */, B5EED1A820F4F3CF00652449 /* Account+ReadOnlyConvertible.swift in Sources */, 74D7FFFA221F01E90008CC0E /* ShipmentStore.swift in Sources */, 247CE86825832BEE00F9D9D1 /* MockShippingLabelActionHandler.swift in Sources */, 0232372922F7DA6E00715FAB /* StatsTimeRangeV4.swift in Sources */, 247CE85C25832A5000F9D9D1 /* MockProductVariationActionHandler.swift in Sources */, CEC7D5992CDE360E00111B79 /* WooShippingStore.swift in Sources */, + 02CC7C2C2D2CE5CB00907B83 /* ProductVariationFormatter.swift in Sources */, B5F2AE9720EBB54A00FEDC59 /* FetchedResultsControllerDelegateWrapper.swift in Sources */, 247CE7C02582DC7200F9D9D1 /* ProductImage+Mocks.swift in Sources */, 7492FADF217FB11D00ED2C69 /* SettingAction.swift in Sources */, 450106872399AB3F00E24722 /* TaxClass+ReadOnlyConvertible.swift in Sources */, B9AECD462851DBED00E78584 /* Order+CurrencyFormattedValues.swift in Sources */, 45182D1F27B54D3000B4C05C /* InboxNote+ReadOnlyConvertible.swift in Sources */, + 027ADB6C2D1BF3AD009608DB /* POSVariableParentProduct.swift in Sources */, 022F00C224726090008CD97F /* SiteNotificationCountFileContents.swift in Sources */, 247CE7BC2582DC1E00F9D9D1 /* MockCustomer.swift in Sources */, + CE0FBB1D2D0C5DE3008B7789 /* WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift in Sources */, 029F44CB28D310BA00FCF439 /* ProductSearchFilter.swift in Sources */, 03EB998C2906F1D300F06A39 /* JustInTimeMessageAction.swift in Sources */, D87F614C22657B150031A13B /* AppSettingsStore.swift in Sources */, @@ -2621,6 +2681,7 @@ buildActionMask = 2147483647; files = ( 4552073D25811B4E001CF873 /* ProductAttributeStoreTests.swift in Sources */, + 6801E41C2D1007D200F9DF46 /* MockPOSReceiptsRemote.swift in Sources */, 20D035022CDBBE2A00C0F901 /* MockCardReaderCapableRemote.swift in Sources */, 458C6DE825ACC554009B300D /* AppSettingsStoreTests+ProductsSettings.swift in Sources */, D88303F025E45E6F00C877F9 /* MockCardReaderService.swift in Sources */, @@ -2652,21 +2713,24 @@ D8652E322630741000350F37 /* PaymentIntent+ReceiptParametersTests.swift in Sources */, 029249E8274B8AEE002E9C34 /* MockMediaRemote.swift in Sources */, 022F00C72472963E008CD97F /* NotificationCountStoreTests.swift in Sources */, + 6801E41A2D1002CE00F9DF46 /* POSReceiptServiceTests.swift in Sources */, 578CE7882475D70F00492EBF /* RetrieveProductReviewFromNoteUseCaseTests.swift in Sources */, 027CC11129F7AAEA00614B6E /* MockGenerativeContentRemote.swift in Sources */, 02DF98092A136BFB0009E2EA /* MockSitePluginsRemote.swift in Sources */, 02FF056723DEB2180058E6E7 /* MediaStoreTests.swift in Sources */, + DE87F4062D2BD49700869522 /* AppSettingsStoreTests+OrderFilterHistory.swift in Sources */, B5C9DE272087FF20006B910A /* MockAcountStore.swift in Sources */, 312DB64D268BD22B00AA0EE9 /* AppSettingsStoreTests+CardReaderSettings.swift in Sources */, 453954DC2C91F79B00A3E64A /* MetaDataStoreTests.swift in Sources */, EEB4E2CF29B04D7900371C3C /* StoreOnboardingTasksStoreTests.swift in Sources */, - 687F83722C0EBF8900460AB3 /* POSProductProviderTests.swift in Sources */, + 687F83722C0EBF8900460AB3 /* PointOfSaleItemServiceTests.swift in Sources */, 03EB99922907EBB300F06A39 /* JustInTimeMessageStoreTests.swift in Sources */, 026D52C0238235930092AE05 /* ProductVariationStoreTests.swift in Sources */, 7492FAE1217FB87100ED2C69 /* SettingStoreTests.swift in Sources */, EE4D51B82ACD3C9800783D77 /* MockProductCategoriesRemote.swift in Sources */, 016EFCB02C41559D0016BDAA /* OrderItem+BasePriceTests.swift in Sources */, 45ED4F16239E939A004F1BE3 /* TaxStoreTests.swift in Sources */, + DE423F3A2D0AD08600116F1B /* ReadOnlyConvertibleTests.swift in Sources */, 57264572250BE2E7005BBD7C /* OrdersUpsertUseCaseTests.swift in Sources */, 0286A1BE2A0CC4810099EF94 /* MockFeatureFlagRemote.swift in Sources */, 20D035002CDBBD6400C0F901 /* CommonReaderConfigProviderTests.swift in Sources */, diff --git a/Yosemite/Yosemite/Actions/AccountAction.swift b/Yosemite/Yosemite/Actions/AccountAction.swift index 046592e7827..a536afa64cc 100644 --- a/Yosemite/Yosemite/Actions/AccountAction.swift +++ b/Yosemite/Yosemite/Actions/AccountAction.swift @@ -15,5 +15,6 @@ public enum AccountAction: Action { case synchronizeSitesAndReturnSelectedSiteInfo(siteAddress: String, onCompletion: (Result) -> Void) case synchronizeSitePlan(siteID: Int64, onCompletion: (Result) -> Void) case updateAccountSettings(userID: Int64, tracksOptOut: Bool, onCompletion: (Result) -> Void) + case updateNotificationSettings(notificationSettings: NotificationSettings, onCompletion: (Result) -> Void) case closeAccount(onCompletion: (Result) -> Void) } diff --git a/Yosemite/Yosemite/Actions/AppSettingsAction.swift b/Yosemite/Yosemite/Actions/AppSettingsAction.swift index 31799a03867..5177ae13632 100644 --- a/Yosemite/Yosemite/Actions/AppSettingsAction.swift +++ b/Yosemite/Yosemite/Actions/AppSettingsAction.swift @@ -51,6 +51,22 @@ public enum AppSettingsAction: Action { /// case resetOrdersSettings + // MARK: - Order filter history + + /// Inserts or updates the order filter history + case upsertOrderFilterHistory(filter: StoredOrderSettings.Setting, + onCompletion: (Error?) -> Void) + + /// Retrieves all persisted order filters for a given site + case loadOrderFilterHistory(siteID: Int64, onCompletion: (Result<[StoredOrderSettings.Setting], Error>) -> Void) + + /// Removes a filter from the persisted history + case removeFromOrderFilterHistory(filter: StoredOrderSettings.Setting, + onCompletion: (Error?) -> Void) + + /// Clears all the order filter history for a given site + case resetOrderFilterHistory(siteID: Int64, onCompletion: (Error?) -> Void) + // MARK: - Products Settings /// Loads the products settings diff --git a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift index 58718fd2e15..e852a90d7e2 100644 --- a/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift +++ b/Yosemite/Yosemite/Actions/CardPresentPaymentAction.swift @@ -82,6 +82,9 @@ public enum CardPresentPaymentAction: Action { /// Check the state of available software updates. case observeCardReaderUpdateState(onCompletion: (AnyPublisher) -> Void) + /// Observe TTP Terms and Services accept event + case observeBuiltInCardReaderAcceptToS(onCompletion: (AnyPublisher) -> Void) + /// Update card reader firmware. case startCardReaderUpdate diff --git a/Yosemite/Yosemite/Actions/SystemStatusAction.swift b/Yosemite/Yosemite/Actions/SystemStatusAction.swift index a95fac6b34f..7b75ccc6e96 100644 --- a/Yosemite/Yosemite/Actions/SystemStatusAction.swift +++ b/Yosemite/Yosemite/Actions/SystemStatusAction.swift @@ -22,5 +22,5 @@ public enum SystemStatusAction: Action { /// Fetch system status report for a site given its ID /// - case fetchSystemStatusReport(siteID: Int64, onCompletion: (Result) -> Void) + case fetchSystemStatusReport(siteID: Int64, onCompletion: (Result) -> Void) } diff --git a/Yosemite/Yosemite/Actions/WooShippingAction.swift b/Yosemite/Yosemite/Actions/WooShippingAction.swift index 5325aa37820..0619fa4f0ea 100644 --- a/Yosemite/Yosemite/Actions/WooShippingAction.swift +++ b/Yosemite/Yosemite/Actions/WooShippingAction.swift @@ -8,6 +8,12 @@ public enum WooShippingAction: Action { predefinedOption: WooShippingPredefinedSavedOption? = nil, completion: (Result) -> Void) + /// Deletes a custom package or deactivates a carrier package with provided package ID. + /// + case deletePackage(siteID: Int64, + packageID: String, + completion: (Result) -> Void) + /// Fetch list of shipping label rates for the order. /// case loadLabelRates(siteID: Int64, @@ -20,7 +26,7 @@ public enum WooShippingAction: Action { /// Fetch list of packages. /// case loadPackages(siteID: Int64, - completion: (Result) -> Void) + completion: (Result) -> Void) /// Fetch list of packages. /// @@ -45,4 +51,9 @@ public enum WooShippingAction: Action { labelIDs: [Int64], paperSize: ShippingLabelPaperSize, completion: (Result) -> Void) + + /// Fetch list of origin addresses. + /// + case loadOriginAddresses(siteID: Int64, + completion: (Result<[WooShippingOriginAddress], Error>) -> Void) } diff --git a/Yosemite/Yosemite/Model/Copiable/Models+Copiable.generated.swift b/Yosemite/Yosemite/Model/Copiable/Models+Copiable.generated.swift index c89421af365..2f68cb4edce 100644 --- a/Yosemite/Yosemite/Model/Copiable/Models+Copiable.generated.swift +++ b/Yosemite/Yosemite/Model/Copiable/Models+Copiable.generated.swift @@ -51,6 +51,39 @@ extension Yosemite.JustInTimeMessage { } } +extension Yosemite.POSSimpleProduct { + public func copy( + id: CopiableProp = .copy, + name: CopiableProp = .copy, + formattedPrice: CopiableProp = .copy, + productImageSource: NullableCopiableProp = .copy, + productID: CopiableProp = .copy, + price: CopiableProp = .copy, + productType: CopiableProp = .copy, + bundledItems: CopiableProp<[Networking.ProductBundleItem]> = .copy + ) -> Yosemite.POSSimpleProduct { + let id = id ?? self.id + let name = name ?? self.name + let formattedPrice = formattedPrice ?? self.formattedPrice + let productImageSource = productImageSource ?? self.productImageSource + let productID = productID ?? self.productID + let price = price ?? self.price + let productType = productType ?? self.productType + let bundledItems = bundledItems ?? self.bundledItems + + return Yosemite.POSSimpleProduct( + id: id, + name: name, + formattedPrice: formattedPrice, + productImageSource: productImageSource, + productID: productID, + price: price, + productType: productType, + bundledItems: bundledItems + ) + } +} + extension Yosemite.ProductReviewFromNoteParcel { public func copy( note: CopiableProp = .copy, diff --git a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockOrderActionHandler.swift b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockOrderActionHandler.swift index ca91d88a051..f6a5e74e637 100644 --- a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockOrderActionHandler.swift +++ b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockOrderActionHandler.swift @@ -48,15 +48,9 @@ struct MockOrderActionHandler: MockActionHandler { } private func saveOrders(orders: [Order], onCompletion: @escaping () -> ()) { - let storage = storageManager.viewStorage - - storage.perform { + storageManager.performAndSave({ storage in let updater = OrdersUpsertUseCase(storage: storage) updater.upsert(orders) - } - - storageManager.saveDerivedType(derivedStorage: storage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } } diff --git a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockPaymentActionHandler.swift b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockPaymentActionHandler.swift index b9b41bae509..88999a0f738 100644 --- a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockPaymentActionHandler.swift +++ b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockPaymentActionHandler.swift @@ -16,7 +16,7 @@ struct MockPaymentActionHandler: MockActionHandler { func handle(action: PaymentAction) { switch action { - case .loadSiteCurrentPlan(siteID: let siteID, completion: let completion): + case .loadSiteCurrentPlan(siteID: _, completion: let completion): let mockPlan = WPComSitePlan( id: "mock_plan_id", hasDomainCredit: true, diff --git a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductActionHandler.swift b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductActionHandler.swift index d63a7e912c7..8610cba4d06 100644 --- a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductActionHandler.swift +++ b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductActionHandler.swift @@ -59,14 +59,8 @@ struct MockProductActionHandler: MockActionHandler { } func upsert(products: [Product], onCompletion: @escaping () -> ()) { - let storage = storageManager.writerDerivedStorage - - storage.perform { - productStore.upsertStoredProducts(readOnlyProducts: products, in: storage) - } - - storageManager.saveDerivedType(derivedStorage: storage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ storage in + self.productStore.upsertStoredProducts(readOnlyProducts: products, in: storage) + }, completion: onCompletion, on: .main) } } diff --git a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductReviewAction.swift b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductReviewAction.swift index a890719b6bb..3d760408914 100644 --- a/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductReviewAction.swift +++ b/Yosemite/Yosemite/Model/Mocks/ActionHandlers/MockProductReviewAction.swift @@ -23,7 +23,6 @@ struct MockProductReviewActionHandler: MockActionHandler { // Deletes previous product reviews before saving new ones to avoid duplicate reviews after multiple runs. let storage = storageManager.viewStorage storage.deleteAllObjects(ofType: StorageProductReview.self) - storage.saveIfNeeded() save(mocks: reviews, as: StorageProductReview.self) { error in if let error = error { diff --git a/Yosemite/Yosemite/Model/Model.swift b/Yosemite/Yosemite/Model/Model.swift index 1056c1caa32..03babf05e7c 100644 --- a/Yosemite/Yosemite/Model/Model.swift +++ b/Yosemite/Yosemite/Model/Model.swift @@ -63,6 +63,7 @@ public typealias Note = Networking.Note public typealias NoteBlock = Networking.NoteBlock public typealias NoteMedia = Networking.NoteMedia public typealias NoteRange = Networking.NoteRange +public typealias NotificationSettings = Networking.NotificationSettings public typealias Order = Networking.Order public typealias OrderItem = Networking.OrderItem public typealias OrderItemAttribute = Networking.OrderItemAttribute @@ -157,6 +158,7 @@ public typealias ShippingMethod = Networking.ShippingMethod public typealias Site = Networking.Site public typealias SiteVisibility = Networking.SiteVisibility public typealias SiteAPI = Networking.SiteAPI +public typealias PagedItems = Networking.PagedItems public typealias Post = Networking.Post public typealias SitePlugin = Networking.SitePlugin public typealias SitePluginStatusEnum = Networking.SitePluginStatusEnum @@ -172,6 +174,7 @@ public typealias SubscriptionPeriod = Networking.SubscriptionPeriod public typealias SubscriptionStatus = Networking.SubscriptionStatus public typealias SystemPlugin = Networking.SystemPlugin public typealias SystemStatus = Networking.SystemStatus +public typealias SystemStatusReport = Networking.SystemStatusReport public typealias TaxClass = Networking.TaxClass public typealias TaxRate = Networking.TaxRate public typealias TopEarnerStats = Networking.TopEarnerStats @@ -187,6 +190,7 @@ public typealias WooShippingCreatePackageResponse = Networking.WooShippingCreate public typealias WooShippingPackagesResponse = Networking.WooShippingPackagesResponse public typealias WooShippingPackagePurchase = Networking.WooShippingPackagePurchase public typealias WooShippingPredefinedSavedOption = Networking.WooShippingPredefinedSavedOption +public typealias WooShippingOriginAddress = Networking.WooShippingOriginAddress public typealias WPComPlan = Networking.WPComPlan public typealias WPComSitePlan = Networking.WPComSitePlan public typealias LoadSiteCurrentPlanError = Networking.LoadSiteCurrentPlanError @@ -319,6 +323,12 @@ public typealias FeatureAnnouncementCampaign = Storage.FeatureAnnouncementCampai public typealias FeatureAnnouncementCampaignSettings = Storage.FeatureAnnouncementCampaignSettings public typealias AnalyticsCard = Storage.AnalyticsCard public typealias DashboardCard = Storage.DashboardCard +public typealias StorageWooShippingPackagesResponse = Storage.WooShippingPackagesResponse +public typealias StorageWooShippingCarrierPredefinedOptions = Storage.WooShippingCarrierPredefinedOptions +public typealias StorageWooShippingPredefinedOption = Storage.WooShippingPredefinedOption +public typealias StorageWooShippingPredefinedPackage = Storage.WooShippingPredefinedPackage +public typealias StorageWooShippingCustomPackage = Storage.WooShippingCustomPackage +public typealias StorageWooShippingSavedPredefinedPackage = Storage.WooShippingSavedPredefinedPackage // MARK: - Internal ReadOnly Models diff --git a/Yosemite/Yosemite/Model/Orders/OrderFilterHistory.swift b/Yosemite/Yosemite/Model/Orders/OrderFilterHistory.swift new file mode 100644 index 00000000000..9c590d2ed91 --- /dev/null +++ b/Yosemite/Yosemite/Model/Orders/OrderFilterHistory.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Models a pair of `siteID` and list of order settings +/// These entities will be serialized to a plist file using `AppSettingsStore` +/// +public struct OrderFilterHistory: Codable, Equatable { + /// SiteID: settings + public let history: [Int64: [StoredOrderSettings.Setting]] + + public init(history: [Int64: [StoredOrderSettings.Setting]]) { + self.history = history + } +} diff --git a/Yosemite/Yosemite/Model/Storage/Product+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/Product+ReadOnlyConvertible.swift index 5ecc62155d4..65ddd38c5a9 100644 --- a/Yosemite/Yosemite/Model/Storage/Product+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/Product+ReadOnlyConvertible.swift @@ -125,8 +125,8 @@ extension Storage.Product: ReadOnlyConvertible { name: name, slug: slug, permalink: permalink, - date: date ?? Date(), - dateCreated: dateCreated ?? Date(), + date: date ?? Date(timeIntervalSince1970: 0), + dateCreated: dateCreated ?? Date(timeIntervalSince1970: 0), dateModified: dateModified, dateOnSaleStart: dateOnSaleStart, dateOnSaleEnd: dateOnSaleEnd, diff --git a/Yosemite/Yosemite/Model/Storage/ProductImage+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/ProductImage+ReadOnlyConvertible.swift index 56500f33cad..4b2dc3c7959 100644 --- a/Yosemite/Yosemite/Model/Storage/ProductImage+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/ProductImage+ReadOnlyConvertible.swift @@ -21,7 +21,7 @@ extension Storage.ProductImage: ReadOnlyConvertible { /// public func toReadOnly() -> Yosemite.ProductImage { return ProductImage(imageID: imageID, - dateCreated: dateCreated ?? Date(), + dateCreated: dateCreated ?? Date(timeIntervalSince1970: 0), dateModified: dateModified, src: src, name: name, diff --git a/Yosemite/Yosemite/Model/Storage/ProductVariation+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/ProductVariation+ReadOnlyConvertible.swift index 0e1c8baa61f..fdb24825587 100644 --- a/Yosemite/Yosemite/Model/Storage/ProductVariation+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/ProductVariation+ReadOnlyConvertible.swift @@ -83,7 +83,7 @@ extension Storage.ProductVariation: ReadOnlyConvertible { attributes: productAttributes, image: productImage, permalink: permalink, - dateCreated: dateCreated, + dateCreated: dateCreated ?? Date(timeIntervalSince1970: 0), dateModified: dateModified, dateOnSaleStart: dateOnSaleStart, dateOnSaleEnd: dateOnSaleEnd, diff --git a/Yosemite/Yosemite/Model/Storage/ShippingLabel+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/ShippingLabel+ReadOnlyConvertible.swift index 29b6da38c7a..47fa6acdbe0 100644 --- a/Yosemite/Yosemite/Model/Storage/ShippingLabel+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/ShippingLabel+ReadOnlyConvertible.swift @@ -31,7 +31,7 @@ extension Storage.ShippingLabel: ReadOnlyConvertible { orderID: orderID, shippingLabelID: shippingLabelID, carrierID: carrierID, - dateCreated: dateCreated, + dateCreated: dateCreated ?? Date(timeIntervalSince1970: 0), packageName: packageName, rate: rate, currency: currency, diff --git a/Yosemite/Yosemite/Model/Storage/ShippingLabelRefund+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/ShippingLabelRefund+ReadOnlyConvertible.swift index 7f0730a7b29..f5de5e609c6 100644 --- a/Yosemite/Yosemite/Model/Storage/ShippingLabelRefund+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/ShippingLabelRefund+ReadOnlyConvertible.swift @@ -14,6 +14,7 @@ extension Storage.ShippingLabelRefund: ReadOnlyConvertible { /// Returns a ReadOnly version of the receiver. /// public func toReadOnly() -> Yosemite.ShippingLabelRefund { - .init(dateRequested: dateRequested, status: .init(rawValue: status)) + .init(dateRequested: dateRequested ?? Date(timeIntervalSince1970: 0), + status: .init(rawValue: status)) } } diff --git a/Yosemite/Yosemite/Model/Storage/WCPayCharge+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WCPayCharge+ReadOnlyConvertible.swift index a03a7deaa74..77180700ef6 100644 --- a/Yosemite/Yosemite/Model/Storage/WCPayCharge+ReadOnlyConvertible.swift +++ b/Yosemite/Yosemite/Model/Storage/WCPayCharge+ReadOnlyConvertible.swift @@ -40,7 +40,7 @@ extension Storage.WCPayCharge: ReadOnlyConvertible { amountRefunded: amountRefunded, authorizationCode: authorizationCode, captured: captured, - created: created, + created: created ?? Date(timeIntervalSince1970: 0), currency: currency, paid: paid, paymentIntentID: paymentIntentID, diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..8e3f937a996 --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingCarrierPredefinedOptions+ReadOnlyConvertible.swift @@ -0,0 +1,25 @@ +import Foundation +import Storage + +extension Storage.WooShippingCarrierPredefinedOptions { + var predefinedOptionsArray: [Storage.WooShippingPredefinedOption] { + return predefinedOptions?.toArray() ?? [] + } +} + +// Storage.WooShippingCarrierPredefinedOptions: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingCarrierPredefinedOptions: ReadOnlyConvertible { + /// Updates the Storage.WooShippingCarrierPredefinedOptions with the a ReadOnly WooShippingCarrierPredefinedOptions. + /// + public func update(with carrierPredefinedOptions: Yosemite.WooShippingCarrierPredefinedOptions) { + self.carrierID = carrierPredefinedOptions.carrierID + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingCarrierPredefinedOptions { + .init(carrierID: carrierID ?? "", + predefinedOptions: predefinedOptionsArray.map { $0.toReadOnly() }) + } +} diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingCustomPackage+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingCustomPackage+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..bc350c7e540 --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingCustomPackage+ReadOnlyConvertible.swift @@ -0,0 +1,26 @@ +import Foundation +import Storage + +// Storage.WooShippingCustomPackage: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingCustomPackage: ReadOnlyConvertible { + /// Updates the Storage.WooShippingCustomPackage with the a ReadOnly WooShippingCustomPackage. + /// + public func update(with customPackage: Yosemite.WooShippingCustomPackage) { + self.id = customPackage.id + self.name = customPackage.name + self.dimensions = customPackage.dimensions + self.boxWeight = customPackage.boxWeight + self.rawType = customPackage.rawType + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingCustomPackage { + .init(id: id ?? "", + name: name ?? "", + rawType: rawType ?? "box", + dimensions: dimensions ?? "", + boxWeight: boxWeight) + } +} diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingPackagesResponse+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingPackagesResponse+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..6235e51ef03 --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingPackagesResponse+ReadOnlyConvertible.swift @@ -0,0 +1,27 @@ +import Foundation +import Storage + +extension Storage.WooShippingPackagesResponse { + var allPredefinedOptionsArray: [Storage.WooShippingCarrierPredefinedOptions] { + return allPredefinedOptions?.toArray() ?? [] + } +} + +// Storage.WooShippingPackagesResponse: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingPackagesResponse: ReadOnlyConvertible { + /// Updates the Storage.WooShippingPackagesResponse with the a ReadOnly WooShippingPackagesResponse. + /// + public func update(with packages: Yosemite.WooShippingPackagesResponse) { + self.siteID = packages.siteID + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingPackagesResponse { + .init(siteID: siteID, + customPackages: customPackages?.map { $0.toReadOnly() } ?? [], + savedPredefinedPackages: savedPredefinedPackages?.map { $0.toReadOnly() } ?? [], + allPredefinedOptions: allPredefinedOptionsArray.map { $0.toReadOnly() }) + } +} diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedOption+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedOption+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..1a843541d1f --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedOption+ReadOnlyConvertible.swift @@ -0,0 +1,22 @@ +import Foundation +import Storage + +// Storage.WooShippingPredefinedOption: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingPredefinedOption: ReadOnlyConvertible { + /// Updates the Storage.WooShippingPredefinedOption with the a ReadOnly WooShippingPredefinedOption. + /// + public func update(with option: Yosemite.WooShippingPredefinedOption) { + self.title = option.title + self.providerID = option.providerID + + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingPredefinedOption { + .init(title: title ?? "", + providerID: providerID ?? "", + predefinedPackages: predefinedPackages?.map { $0.toReadOnly() } ?? []) + } +} diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedPackage+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedPackage+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..e78363ee02f --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingPredefinedPackage+ReadOnlyConvertible.swift @@ -0,0 +1,28 @@ +import Foundation +import Storage + +// Storage.WooShippingPredefinedPackage: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingPredefinedPackage: ReadOnlyConvertible { + /// Updates the Storage.WooShippingPredefinedPackage with the a ReadOnly WooShippingPredefinedPackage. + /// + public func update(with predefinedPackage: Yosemite.WooShippingPredefinedPackage) { + self.id = predefinedPackage.id + self.name = predefinedPackage.name + self.isLetter = predefinedPackage.isLetter + self.dimensions = predefinedPackage.dimensions + self.boxWeight = predefinedPackage.boxWeight + self.groupID = predefinedPackage.groupId + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingPredefinedPackage { + .init(id: id ?? "", + name: name ?? "", + isLetter: isLetter, + dimensions: dimensions ?? "", + boxWeight: boxWeight ?? "", + groupId: groupID ?? "") + } +} diff --git a/Yosemite/Yosemite/Model/Storage/WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift b/Yosemite/Yosemite/Model/Storage/WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..466e9a5ff04 --- /dev/null +++ b/Yosemite/Yosemite/Model/Storage/WooShippingSavedPredefinedPackage+ReadOnlyConvertible.swift @@ -0,0 +1,31 @@ +import Foundation +import Storage + +// Storage.WooShippingSavedPredefinedPackage: ReadOnlyConvertible Conformance. +// +extension Storage.WooShippingSavedPredefinedPackage: ReadOnlyConvertible { + /// Updates the Storage.WooShippingSavedPredefinedPackage with the a ReadOnly WooShippingSavedPredefinedPackage. + /// + public func update(with savedPackage: Yosemite.WooShippingSavedPredefinedPackage) { + self.groupTitle = savedPackage.groupTitle + self.providerID = savedPackage.providerID + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.WooShippingSavedPredefinedPackage { + .init(groupTitle: groupTitle ?? "", + providerID: providerID ?? "", + package: createReadOnlyPackage()) + } + + // MARK: Private helpers + + private func createReadOnlyPackage() -> Yosemite.WooShippingPredefinedPackage { + guard let package else { + return WooShippingPredefinedPackage(id: "", name: "", isLetter: false, dimensions: "", boxWeight: "", groupId: "") + } + + return package.toReadOnly() + } +} diff --git a/Yosemite/Yosemite/PointOfSale/POSProduct.swift b/Yosemite/Yosemite/PointOfSale/POSProduct.swift deleted file mode 100644 index f246dfbfc5f..00000000000 --- a/Yosemite/Yosemite/PointOfSale/POSProduct.swift +++ /dev/null @@ -1,29 +0,0 @@ -import WooFoundation - -struct POSProduct: POSOrderableItem, OrderSyncProductTypeProtocol, Equatable { - // POSOrderableItem - let id: UUID - let name: String - let formattedPrice: String - var productImageSource: String? - - // OrderSyncProductTypeProtocol - let productID: Int64 - let price: String - let productType: ProductType = .simple - let bundledItems: [ProductBundleItem] = [] -} - -extension POSProduct: Hashable { - func toOrderSyncProductInput(quantity: Decimal) -> OrderSyncProductInput { - OrderSyncProductInput(product: .product(self), quantity: quantity) - } - - func matches(orderItem: OrderItem) -> Bool { - // TODO: https://github.com/woocommerce/woocommerce-ios/pull/13328/files#r1687631533 - // - we should also add a logic to compare prices - // - but we should be aware of the fact that some - // products already have tax in the price - return productID == orderItem.productID - } -} diff --git a/Yosemite/Yosemite/PointOfSale/POSSimpleProduct.swift b/Yosemite/Yosemite/PointOfSale/POSSimpleProduct.swift new file mode 100644 index 00000000000..c0665ae3fa6 --- /dev/null +++ b/Yosemite/Yosemite/PointOfSale/POSSimpleProduct.swift @@ -0,0 +1,49 @@ +import WooFoundation +import Codegen +import Networking + +public struct POSSimpleProduct: POSOrderableItem, OrderSyncProductTypeProtocol { + // POSOrderableItem + public let id: UUID + public let name: String + public let formattedPrice: String + public var productImageSource: String? + + // OrderSyncProductTypeProtocol + public let productID: Int64 + public let price: String + public let productType: ProductType = .simple + public let bundledItems: [ProductBundleItem] = [] + + public init(id: UUID, + name: String, + formattedPrice: String, + productImageSource: String? = nil, + productID: Int64, + price: String, + productType: ProductType = .simple, + bundledItems: [ProductBundleItem] = []) { + self.id = id + self.name = name + self.formattedPrice = formattedPrice + self.productImageSource = productImageSource + self.productID = productID + self.price = price + } +} + +extension POSSimpleProduct { + public func toOrderSyncProductInput(quantity: Decimal) -> OrderSyncProductInput { + OrderSyncProductInput(product: .product(self), quantity: quantity) + } + + public func matches(orderItem: OrderItem) -> Bool { + // TODO: https://github.com/woocommerce/woocommerce-ios/pull/13328/files#r1687631533 + // - we should also add a logic to compare prices + // - but we should be aware of the fact that some + // products already have tax in the price + return productID == orderItem.productID + } +} + +extension POSSimpleProduct: GeneratedFakeable, GeneratedCopiable, Hashable, Equatable {} diff --git a/Yosemite/Yosemite/PointOfSale/POSVariableParentProduct.swift b/Yosemite/Yosemite/PointOfSale/POSVariableParentProduct.swift new file mode 100644 index 00000000000..e8252cf1b55 --- /dev/null +++ b/Yosemite/Yosemite/PointOfSale/POSVariableParentProduct.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct POSVariableParentProduct: Equatable, Hashable, Identifiable { + public let id: UUID + public let name: String + public let productImageSource: String? + public let productID: Int64 + let allAttributes: [ProductAttribute] + + init(id: UUID, name: String, productImageSource: String?, productID: Int64, allAttributes: [ProductAttribute]) { + self.id = id + self.name = name + self.productImageSource = productImageSource + self.productID = productID + self.allAttributes = allAttributes + } + + #if DEBUG + + /// Initializer for SwiftUI previews. + public init(id: UUID, name: String, productImageSource: String?, productID: Int64) { + self.init(id: id, name: name, productImageSource: productImageSource, productID: productID, allAttributes: []) + } + + #endif +} diff --git a/Yosemite/Yosemite/PointOfSale/POSVariation.swift b/Yosemite/Yosemite/PointOfSale/POSVariation.swift new file mode 100644 index 00000000000..7a59d765b09 --- /dev/null +++ b/Yosemite/Yosemite/PointOfSale/POSVariation.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct POSVariation: OrderSyncProductVariationTypeProtocol, Equatable, Hashable, Identifiable { + // Identifiable & POSOrderableItem + public let id: UUID + + // POSOrderableItem + public let name: String + public let formattedPrice: String + public var productImageSource: String? + + // OrderSyncProductVariationTypeProtocol + public let productID: Int64 + public let productVariationID: Int64 + public let price: String + + public init(id: UUID, name: String, formattedPrice: String, price: String, productImageSource: String? = nil, productID: Int64, variationID: Int64) { + self.id = id + self.name = name + self.formattedPrice = formattedPrice + self.price = price + self.productImageSource = productImageSource + self.productID = productID + self.productVariationID = variationID + } +} + +extension POSVariation: POSOrderableItem { + public func toOrderSyncProductInput(quantity: Decimal) -> OrderSyncProductInput { + OrderSyncProductInput(product: .variation(self), quantity: quantity) + } + + public func matches(orderItem: OrderItem) -> Bool { + productID == orderItem.productID && productVariationID == orderItem.variationID + } +} diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift new file mode 100644 index 00000000000..d8fc8d41044 --- /dev/null +++ b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift @@ -0,0 +1,141 @@ +import Foundation +import protocol Networking.Network +import protocol Networking.ProductVariationsRemoteProtocol +import class Networking.ProductsRemote +import class Networking.ProductVariationsRemote +import class Networking.AlamofireNetwork +import class WooFoundation.CurrencyFormatter +import class WooFoundation.CurrencySettings + +public enum PointOfSaleItemServiceError: Error, Equatable { + case requestFailed + case unknown +} + +/// Product provider for the Point of Sale feature +/// +public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { + private var siteID: Int64 + private let currencyFormatter: CurrencyFormatter + private let productsRemote: ProductsRemote + private let variationRemote: ProductVariationsRemoteProtocol + private let isVariableProductsFeatureEnabled: Bool + + public init(siteID: Int64, currencySettings: CurrencySettings, network: Network, isVariableProductsFeatureEnabled: Bool) { + self.siteID = siteID + self.currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) + self.productsRemote = ProductsRemote(network: network) + self.variationRemote = ProductVariationsRemote(network: network) + self.isVariableProductsFeatureEnabled = isVariableProductsFeatureEnabled + } + + public convenience init(siteID: Int64, + currencySettings: CurrencySettings, + credentials: Credentials?, + isVariableProductsFeatureEnabled: Bool) { + self.init(siteID: siteID, + currencySettings: currencySettings, + network: AlamofireNetwork(credentials: credentials), + isVariableProductsFeatureEnabled: isVariableProductsFeatureEnabled) + } + + /// Provides a list of products for the Point of Sale, by fetching simple products from the remote, applying any eligibility criteria, + /// and maps them to POSItem type. + /// + /// - pageNumber: Number of the page that should be retrieved. If none given, defaults to 1 + /// + public func providePointOfSaleItems(pageNumber: Int = 1) async throws -> PagedItems { + let productTypes: [ProductType] = isVariableProductsFeatureEnabled ? + [.simple, .variable] : [.simple] + let pagedProducts = try await productsRemote.loadProductsForPointOfSale(for: siteID, productTypes: productTypes, pageNumber: pageNumber) + let products = pagedProducts.items + + if pageNumber != 1 && products.count == 0 { + return .init(items: [], hasMorePages: false) + } + + let eligibilityCriteria: [(Product) -> Bool] = [ + isNotVirtual, + isNotDownloadable, + hasPrice + ] + let filteredProducts = filterProducts(products: products, using: eligibilityCriteria) + + return .init(items: mapProductsToPOSItems(products: filteredProducts), hasMorePages: pagedProducts.hasMorePages) + } + + public func providePointOfSaleVariationItems(for parentProduct: POSVariableParentProduct, pageNumber: Int) async throws -> PagedItems { + let variations = try await variationRemote + .loadVariationsForPointOfSale(for: siteID, + parentProductID: parentProduct.productID, + pageNumber: pageNumber) + return .init( + items: variations.compactMap({ variation in + let variationName = ProductVariationFormatter().generateName( + for: variation, + from: parentProduct.allAttributes + ) + return POSItem + .variation(.init(id: UUID(), + name: variationName, + formattedPrice: currencyFormatter.formatAmount(variation.price) ?? "-", + price: variation.price, + productImageSource: variation.image?.src, + productID: variation.productID, + variationID: variation.productVariationID)) + }), + // TODO-14696: pagination support for variations lists + hasMorePages: false + ) + } + + // Maps result to POSItem, and populate the output with: + // - Formatted price based on store's currency settings. + // - Product thumbnail, if any. + private func mapProductsToPOSItems(products: [Product]) -> [POSItem] { + return products.compactMap { product in + let formattedPrice = currencyFormatter.formatAmount(product.price) ?? "-" + let thumbnailSource = product.images.first?.src + + switch product.productType { + case .simple: + return .simpleProduct(POSSimpleProduct(id: UUID(), + name: product.name, + formattedPrice: formattedPrice, + productImageSource: thumbnailSource, + productID: product.productID, + price: product.price)) + case .variable: + return .variableParentProduct(POSVariableParentProduct( + id: UUID(), + name: product.name, + productImageSource: thumbnailSource, + productID: product.productID, + allAttributes: product.attributesForVariations + )) + default: + return nil + } + } + } +} + +private extension PointOfSaleItemService { + func filterProducts(products: [Product], using criteria: [(Product) -> Bool]) -> [Product] { + return products.filter { product in + criteria.allSatisfy { $0(product) } + } + } + + func isNotVirtual(product: Product) -> Bool { + !product.virtual + } + + func isNotDownloadable(product: Product) -> Bool { + !product.downloadable + } + + func hasPrice(product: Product) -> Bool { + !product.price.isEmpty + } +} diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemServiceProtocol.swift b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemServiceProtocol.swift index 385d783cab3..3c138cbaaff 100644 --- a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemServiceProtocol.swift +++ b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemServiceProtocol.swift @@ -1,23 +1,45 @@ -/// POSDisplayableItem contains only the properties required to show an item in the Point Of Sale. -/// The item may only be visible, not neccesarily something you can add to the cart. -/// This protocol will become less specific in future; e.g. not all items in the POS necessarily have a price. -public protocol POSDisplayableItem { +import struct Networking.PagedItems + +public enum POSItem: Equatable, Identifiable, Hashable { + case simpleProduct(POSSimpleProduct) + case variableParentProduct(POSVariableParentProduct) + case variation(POSVariation) + + public var id: UUID { + switch self { + case .simpleProduct(let product): + return product.id + case .variableParentProduct(let parentProduct): + return parentProduct.id + case .variation(let variation): + return variation.id + } + } +} + +/// POSOrderableItem extends a displayable item with the functions required for using it in an order. +/// This currently includes adding it, and checking whether it's already in an order. +/// This may need to become less specific in future, e.g. we currently convert it to a product input, but +/// other order items might be added as fees or similar. at that point, we will need a different function requirement here. +public protocol POSOrderableItem { var id: UUID { get } var name: String { get } - var formattedPrice: String { get } var productImageSource: String? { get } + var formattedPrice: String { get } - func isEqual(to other: POSDisplayableItem) -> Bool + func toOrderSyncProductInput(quantity: Decimal) -> OrderSyncProductInput + func matches(orderItem: OrderItem) -> Bool + func isEqual(to other: POSOrderableItem) -> Bool } -public extension POSDisplayableItem where Self: Equatable { - func isEqual(to other: POSDisplayableItem) -> Bool { +public extension POSOrderableItem where Self: Equatable { + func isEqual(to other: POSOrderableItem) -> Bool { guard let other = other as? Self else { return false } return self == other } } -public extension Sequence where Element == POSDisplayableItem { +public extension Sequence where Element == POSOrderableItem { func isEqual(to other: any Sequence) -> Bool { let lhsArray = Array(self) let rhsArray = Array(other) @@ -30,25 +52,15 @@ public extension Sequence where Element == POSDisplayableItem { } } -/// POSOrderableItem extends a displayable item with the functions required for using it in an order. -/// This currently includes adding it, and checking whether it's already in an order. -/// This may need to become less specific in future, e.g. we currently convert it to a product input, but -/// other order items might be added as fees or similar. at that point, we will need a different function requirement here. -public protocol POSOrderableItem: POSDisplayableItem & PointOfSaleItemOrderItemConvertable {} - -public protocol PointOfSaleItemOrderItemConvertable { - func toOrderSyncProductInput(quantity: Decimal) -> OrderSyncProductInput - func matches(orderItem: OrderItem) -> Bool -} - public protocol PointOfSaleItemServiceProtocol { - func providePointOfSaleItems(pageNumber: Int) async throws -> [POSDisplayableItem] + func providePointOfSaleItems(pageNumber: Int) async throws -> PagedItems + func providePointOfSaleVariationItems(for parentProduct: POSVariableParentProduct, pageNumber: Int) async throws -> PagedItems } // Default implementation for convenience, so we do not need to pass the first page explicitly // if no pageNumber is given. extension PointOfSaleItemServiceProtocol { - func providePointOfSaleItems(pageNumber: Int = 1) async throws -> [POSDisplayableItem] { + func providePointOfSaleItems(pageNumber: Int = 1) async throws -> PagedItems { try await providePointOfSaleItems(pageNumber: pageNumber) } } diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSaleProductService.swift b/Yosemite/Yosemite/PointOfSale/PointOfSaleProductService.swift deleted file mode 100644 index 6c55e81975f..00000000000 --- a/Yosemite/Yosemite/PointOfSale/PointOfSaleProductService.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import protocol Networking.Network -import class Networking.ProductsRemote -import class Networking.AlamofireNetwork -import class WooFoundation.CurrencyFormatter -import class WooFoundation.CurrencySettings - -public enum PointOfSaleProductServiceError: Error { - case requestFailed - case pageOutOfRange - case unknown -} - -/// Product provider for the Point of Sale feature -/// -public final class PointOfSaleProductService: PointOfSaleItemServiceProtocol { - private var siteID: Int64 - private var currencySettings: CurrencySettings - private let productsRemote: ProductsRemote - - public init(siteID: Int64, currencySettings: CurrencySettings, network: Network) { - self.siteID = siteID - self.currencySettings = currencySettings - self.productsRemote = ProductsRemote(network: network) - } - - public convenience init(siteID: Int64, - currencySettings: CurrencySettings, - credentials: Credentials?) { - self.init(siteID: siteID, - currencySettings: currencySettings, - network: AlamofireNetwork(credentials: credentials)) - } - - /// Provides a list of products for the Point of Sale, by fetching simple products from the remote, applying any eligibility criteria, - /// and maps them to POSItem type. - /// - /// - pageNumber: Number of the page that should be retrieved. If none given, defaults to 1 - /// - public func providePointOfSaleItems(pageNumber: Int = 1) async throws -> [POSDisplayableItem] { - let products = try await productsRemote.loadSimpleProductsForPointOfSale(for: siteID, pageNumber: pageNumber) - - if pageNumber != 1 && products.count == 0 { - throw PointOfSaleProductServiceError.pageOutOfRange - } - - let eligibilityCriteria: [(Product) -> Bool] = [ - isNotVirtual, - isNotDownloadable, - hasPrice - ] - let filteredProducts = filterProducts(products: products, using: eligibilityCriteria) - - return mapProductsToPOSItems(products: filteredProducts) - } - - // Maps result to POSProduct, and populate the output with: - // - Formatted price based on store's currency settings. - // - Product thumbnail, if any. - private func mapProductsToPOSItems(products: [Product]) -> [POSOrderableItem] { - let currencyFormatter = CurrencyFormatter(currencySettings: currencySettings) - return products.map { product in - let formattedPrice = currencyFormatter.formatAmount(product.price) ?? "-" - let thumbnailSource = product.images.first?.src - - return POSProduct(id: UUID(), - name: product.name, - formattedPrice: formattedPrice, - productImageSource: thumbnailSource, - productID: product.productID, - price: product.price) - } - } -} - -private extension PointOfSaleProductService { - func filterProducts(products: [Product], using criteria: [(Product) -> Bool]) -> [Product] { - return products.filter { product in - criteria.allSatisfy { $0(product) } - } - } - - func isNotVirtual(product: Product) -> Bool { - !product.virtual - } - - func isNotDownloadable(product: Product) -> Bool { - !product.downloadable - } - - func hasPrice(product: Product) -> Bool { - !product.price.isEmpty - } -} diff --git a/Yosemite/Yosemite/Stores/AccountStore.swift b/Yosemite/Yosemite/Stores/AccountStore.swift index 717af1fd5af..47642a4a386 100644 --- a/Yosemite/Yosemite/Stores/AccountStore.swift +++ b/Yosemite/Yosemite/Stores/AccountStore.swift @@ -56,6 +56,8 @@ public class AccountStore: Store { synchronizeSitePlan(siteID: siteID, onCompletion: onCompletion) case .updateAccountSettings(let userID, let tracksOptOut, let onCompletion): updateAccountSettings(userID: userID, tracksOptOut: tracksOptOut, onCompletion: onCompletion) + case .updateNotificationSettings(let notificationSettings, let onCompletion): + updateNotificationSettings(notificationSettings: notificationSettings, onCompletion: onCompletion) case .closeAccount(let onCompletion): closeAccount(onCompletion: onCompletion) } @@ -223,6 +225,17 @@ private extension AccountStore { } } + func updateNotificationSettings(notificationSettings: NotificationSettings, onCompletion: @escaping (Result) -> Void) { + Task { + do { + try await remote.updateNotificationSettings(with: notificationSettings) + onCompletion(.success(())) + } catch { + onCompletion(.failure(error)) + } + } + } + func closeAccount(onCompletion: @escaping (Result) -> Void) { Task { do { diff --git a/Yosemite/Yosemite/Stores/AddOnGroupStore.swift b/Yosemite/Yosemite/Stores/AddOnGroupStore.swift index 99d1025c24d..b17a3bc3de6 100644 --- a/Yosemite/Yosemite/Stores/AddOnGroupStore.swift +++ b/Yosemite/Yosemite/Stores/AddOnGroupStore.swift @@ -56,24 +56,19 @@ private extension AddOnGroupStore { /// onCompletion will be called on the main thread! /// func upsertAddOnGroupsInBackground(siteID: Int64, readOnlyAddOnGroups: [AddOnGroup], onCompletion: @escaping (Result) -> Void) { - let derivedStorage = storageManager.writerDerivedStorage - derivedStorage.perform { - self.upsertAddOnGroups(siteID: siteID, readOnlyAddOnGroups: readOnlyAddOnGroups, in: derivedStorage) - } - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async { - onCompletion(.success(())) - } - } + storageManager.performAndSave({ [weak self] storage in + self?.upsertAddOnGroups(siteID: siteID, readOnlyAddOnGroups: readOnlyAddOnGroups, in: storage) + }, completion: onCompletion, on: .main) } /// Updates (OR Inserts) the specified ReadOnly `AddOnGroups` entities into the Storage Layer. /// func upsertAddOnGroups(siteID: Int64, readOnlyAddOnGroups: [AddOnGroup], in storage: StorageType) { + let storedGroups = storage.loadAddOnGroups(siteID: siteID) readOnlyAddOnGroups.forEach { readOnlyAddOnGroup in // Get or create the stored add-on group let storedAddOnGroup: StorageAddOnGroup = { - guard let existingGroup = storage.loadAddOnGroup(siteID: siteID, groupID: readOnlyAddOnGroup.groupID) else { + guard let existingGroup = storedGroups.first(where: { $0.groupID == readOnlyAddOnGroup.groupID }) else { return storage.insertNewObject(ofType: StorageAddOnGroup.self) } return existingGroup @@ -86,7 +81,10 @@ private extension AddOnGroupStore { // Delete stale groups let activeIDs = readOnlyAddOnGroups.map { $0.groupID } - storage.deleteStaleAddOnGroups(siteID: siteID, activeGroupIDs: activeIDs) + let staleGroups = storedGroups.filter { !activeIDs.contains($0.groupID) } + staleGroups.forEach { + storage.deleteObject($0) + } } /// Replaces the `storageGroup.addOns` with the new `readOnlyGroup.addOns` diff --git a/Yosemite/Yosemite/Stores/AppSettingsStore.swift b/Yosemite/Yosemite/Stores/AppSettingsStore.swift index 4a6c3719315..cb2e4169788 100644 --- a/Yosemite/Yosemite/Stores/AppSettingsStore.swift +++ b/Yosemite/Yosemite/Stores/AppSettingsStore.swift @@ -53,6 +53,12 @@ public class AppSettingsStore: Store { return documents!.appendingPathComponent(Constants.ordersSettings) }() + /// URL to the plist file containing the persisted order filter history + private lazy var orderFilterHistoryURL: URL = { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + return documents!.appendingPathComponent(Constants.orderFilterHistory) + }() + /// URL to the plist file that we use to determine the settings applied in Products /// private lazy var productsSettingsURL: URL = { @@ -118,6 +124,14 @@ public class AppSettingsStore: Store { onCompletion: onCompletion) case .resetOrdersSettings: resetOrdersSettings() + case let .upsertOrderFilterHistory(filter, onCompletion): + upsertOrderFilterHistory(filter: filter, onCompletion: onCompletion) + case let .loadOrderFilterHistory(siteID, onCompletion): + loadOrderFilterHistory(siteID: siteID, onCompletion: onCompletion) + case let .removeFromOrderFilterHistory(filter, onCompletion): + removeFromOrderFilterHistory(filter: filter, onCompletion: onCompletion) + case let .resetOrderFilterHistory(siteID, onCompletion): + resetOrderFilterHistory(siteID: siteID, onCompletion: onCompletion) case .loadProductsSettings(let siteID, let onCompletion): loadProductsSettings(siteID: siteID, onCompletion: onCompletion) case .upsertProductsSettings(let siteID, @@ -708,6 +722,89 @@ private extension AppSettingsStore { } } +// MARK: - Order filter history +private extension AppSettingsStore { + /// Inserts or update the order filter history + func upsertOrderFilterHistory(filter: StoredOrderSettings.Setting, + onCompletion: @escaping (Error?) -> Void) { + var existingHistory: [Int64: [StoredOrderSettings.Setting]] = [:] + if let storedHistory: OrderFilterHistory = try? fileStorage.data(for: orderFilterHistoryURL) { + existingHistory = storedHistory.history + } + + var siteHistory = existingHistory[filter.siteID] ?? [] + if siteHistory.contains(filter) { + siteHistory = [filter] + siteHistory.filter { $0 != filter } // move the filter to the top + } else { + siteHistory.append(filter) + } + existingHistory[filter.siteID] = siteHistory + + let newStoredHistory = OrderFilterHistory(history: existingHistory) + do { + try fileStorage.write(newStoredHistory, to: orderFilterHistoryURL) + onCompletion(nil) + } catch { + onCompletion(AppSettingsStoreErrors.writeOrderFilterHistory) + } + } + + /// Retrieves all persisted order filters for a given site + func loadOrderFilterHistory(siteID: Int64, + onCompletion: @escaping (Result<[StoredOrderSettings.Setting], Error>) -> Void) { + guard let allHistory: OrderFilterHistory = try? fileStorage.data(for: orderFilterHistoryURL), + let siteHistory = allHistory.history[siteID] else { + let error = AppSettingsStoreErrors.noOrderFilterHistory + onCompletion(.failure(error)) + return + } + + onCompletion(.success(siteHistory)) + } + + /// Removes a filter from the persisted history + func removeFromOrderFilterHistory(filter: StoredOrderSettings.Setting, + onCompletion: @escaping (Error?) -> Void) { + var existingHistory: [Int64: [StoredOrderSettings.Setting]] = [:] + if let storedHistory: OrderFilterHistory = try? fileStorage.data(for: orderFilterHistoryURL) { + existingHistory = storedHistory.history + } + + guard let siteHistory = existingHistory[filter.siteID] else { + onCompletion(nil) + return + } + + existingHistory[filter.siteID] = siteHistory.filter { $0 != filter } + + let newStoredHistory = OrderFilterHistory(history: existingHistory) + do { + try fileStorage.write(newStoredHistory, to: orderFilterHistoryURL) + onCompletion(nil) + } catch { + onCompletion(AppSettingsStoreErrors.writeOrderFilterHistory) + } + } + + /// Clears all the order filter history for a given site + func resetOrderFilterHistory(siteID: Int64, onCompletion: @escaping (Error?) -> Void) { + var existingHistory: [Int64: [StoredOrderSettings.Setting]] = [:] + if let storedHistory: OrderFilterHistory = try? fileStorage.data(for: orderFilterHistoryURL) { + existingHistory = storedHistory.history + } + + existingHistory[siteID]?.removeAll() + + let newStoredHistory = OrderFilterHistory(history: existingHistory) + do { + try fileStorage.write(newStoredHistory, to: orderFilterHistoryURL) + onCompletion(nil) + } catch { + onCompletion(AppSettingsStoreErrors.writeOrderFilterHistory) + } + } +} + // MARK: - Products Settings // private extension AppSettingsStore { @@ -1098,7 +1195,9 @@ enum AppSettingsStoreErrors: Error { case writePListToFileStorage case noOrdersSettings case noProductsSettings + case noOrderFilterHistory case writeOrdersSettings + case writeOrderFilterHistory case writeProductsSettings case noEligibilityErrorInfo } @@ -1116,4 +1215,5 @@ private enum Constants { static let generalStoreSettingsFileName = "general-store-settings.plist" static let ordersSettings = "orders-settings.plist" static let productsSettings = "products-settings.plist" + static let orderFilterHistory = "order-filter-history.plist" } diff --git a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift index 403b6becac5..fa750330539 100644 --- a/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift +++ b/Yosemite/Yosemite/Stores/CardPresentPaymentStore.swift @@ -129,6 +129,8 @@ public final class CardPresentPaymentStore: Store { cancelRefund(onCompletion: completion) case .observeCardReaderUpdateState(onCompletion: let completion): observeCardReaderUpdateState(onCompletion: completion) + case .observeBuiltInCardReaderAcceptToS(let completion): + observeBuiltInCardReaderAcceptToS(onCompletion: completion) case .startCardReaderUpdate: startCardReaderUpdate() case .reset: @@ -400,6 +402,10 @@ private extension CardPresentPaymentStore { onCompletion(cardReaderService.softwareUpdateEvents) } + func observeBuiltInCardReaderAcceptToS(onCompletion: @escaping (AnyPublisher) -> Void) { + onCompletion(cardReaderService.builtInCardReaderAcceptToSEvents) + } + func startCardReaderUpdate() { cardReaderService.installUpdate() } diff --git a/Yosemite/Yosemite/Stores/CustomerStore.swift b/Yosemite/Yosemite/Stores/CustomerStore.swift index 0b859c730a3..6225093c782 100644 --- a/Yosemite/Yosemite/Stores/CustomerStore.swift +++ b/Yosemite/Yosemite/Stores/CustomerStore.swift @@ -5,9 +5,6 @@ import Storage public final class CustomerStore: Store { private let customerRemote: CustomerRemote private let wcAnalyticsCustomerRemote: WCAnalyticsCustomerRemote - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() init(dispatcher: Dispatcher, storageManager: StorageManagerType, @@ -121,11 +118,10 @@ public final class CustomerStore: Store { self.mapSearchResultsToCustomerObjects(for: siteID, with: keyword, with: customers, onCompletion: onCompletion) } else { self.upsertCustomersAndSave(siteID: siteID, - readOnlyCustomers: customers, - shouldDeleteExistingCustomers: pageNumber == 1, - keyword: keyword, - in: self.sharedDerivedStorage, - onCompletion: { + readOnlyCustomers: customers, + shouldDeleteExistingCustomers: pageNumber == 1, + keyword: keyword, + onCompletion: { onCompletion(.success(())) }) } @@ -163,8 +159,7 @@ public final class CustomerStore: Store { self.upsertWCAnalyticsCustomersAndSave(siteID: siteID, readOnlyCustomers: customers, shouldDeleteExistingCustomers: filter != .all, - keyword: keyword, - in: self.sharedDerivedStorage) { + keyword: keyword) { let hasNextPage = customers.count == pageSize onCompletion(.success(hasNextPage)) } @@ -190,7 +185,7 @@ public final class CustomerStore: Store { guard let self else { return } switch result { case .success(let customer): - self.upsertCustomersAndSave(siteID: siteID, readOnlyCustomers: [customer], in: self.sharedDerivedStorage, onCompletion: { + self.upsertCustomersAndSave(siteID: siteID, readOnlyCustomers: [customer], onCompletion: { onCompletion(.success(customer)) }) case .failure(let error): @@ -215,10 +210,9 @@ public final class CustomerStore: Store { switch result { case .success(let customers): self.upsertCustomersAndSave(siteID: siteID, - readOnlyCustomers: customers, - shouldDeleteExistingCustomers: pageNumber == 1, - in: self.sharedDerivedStorage, - onCompletion: { + readOnlyCustomers: customers, + shouldDeleteExistingCustomers: pageNumber == 1, + onCompletion: { onCompletion(.success(!customers.isEmpty)) }) case .failure(let error): @@ -241,8 +235,7 @@ public final class CustomerStore: Store { case let .success(customers): self.upsertWCAnalyticsCustomersAndSave(siteID: siteID, readOnlyCustomers: customers, - shouldDeleteExistingCustomers: pageNumber == 1, - in: self.sharedDerivedStorage) { + shouldDeleteExistingCustomers: pageNumber == 1) { let hasNextPage = customers.count == pageSize onCompletion(.success(hasNextPage)) } @@ -253,13 +246,9 @@ public final class CustomerStore: Store { } func deleteAllCustomers(from siteID: Int64, onCompletion: @escaping () -> Void) { - sharedDerivedStorage.perform { [weak self] in - self?.sharedDerivedStorage.deleteCustomers(siteID: siteID) - } - - storageManager.saveDerivedType(derivedStorage: sharedDerivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ storage in + storage.deleteCustomers(siteID: siteID) + }, completion: onCompletion, on: .main) } /// Maps CustomerSearchResult to Customer objects @@ -313,117 +302,130 @@ private extension CustomerStore { keyword: String, readOnlyCustomers: [Networking.Customer], onCompletion: @escaping () -> Void) { - sharedDerivedStorage.perform { [weak self] in - guard let self = self else { return } - let storedSearchResult = self.sharedDerivedStorage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) ?? - self.sharedDerivedStorage.insertNewObject(ofType: Storage.CustomerSearchResult.self) + storageManager.performAndSave({ storage in + let storedSearchResult = storage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) ?? + storage.insertNewObject(ofType: Storage.CustomerSearchResult.self) storedSearchResult.siteID = siteID storedSearchResult.keyword = keyword + let storedCustomers = storage.loadCustomers(siteID: siteID, matching: readOnlyCustomers.map { $0.customerID }) for result in readOnlyCustomers { - if let storedCustomer = self.sharedDerivedStorage.loadCustomer(siteID: siteID, customerID: result.customerID) { + if let storedCustomer = storedCustomers.first(where: { $0.customerID == result.customerID }) { storedSearchResult.addToCustomers(storedCustomer) } } - } - storageManager.saveDerivedType(derivedStorage: self.sharedDerivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } private func upsertCustomersAndSave(siteID: Int64, - readOnlyCustomers: [StorageCustomerConvertible], - shouldDeleteExistingCustomers: Bool = false, - keyword: String? = nil, - in storage: StorageType, - onCompletion: @escaping () -> Void) { - storage.perform { [weak self] in + readOnlyCustomers: [StorageCustomerConvertible], + shouldDeleteExistingCustomers: Bool = false, + keyword: String? = nil, + onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ [weak self] storage in if shouldDeleteExistingCustomers { storage.deleteCustomers(siteID: siteID) } + let storedCustomers = storage.loadCustomers(siteID: siteID, matching: readOnlyCustomers.map { $0.loadingID }) + let storedSearchResult: CustomerSearchResult? = { + guard let keyword else { + return nil + } + if let result = storage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) { + return result + } else { + let result = storage.insertNewObject(ofType: Storage.CustomerSearchResult.self) + result.siteID = siteID + result.keyword = keyword + return result + } + }() + readOnlyCustomers.forEach { - self?.upsertCustomer(siteID: siteID, readOnlyCustomer: $0, keyword: keyword, in: storage) + self?.upsertCustomer(siteID: siteID, + readOnlyCustomer: $0, + storedCustomers: storedCustomers, + storedSearchResult: storedSearchResult, + in: storage) } - } - - storageManager.saveDerivedType(derivedStorage: storage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } private func upsertWCAnalyticsCustomersAndSave(siteID: Int64, readOnlyCustomers: [WCAnalyticsCustomer], shouldDeleteExistingCustomers: Bool = false, keyword: String? = nil, - in storage: StorageType, onCompletion: @escaping () -> Void) { - storage.perform { [weak self] in + storageManager.performAndSave({ [weak self] storage in if shouldDeleteExistingCustomers { storage.deleteWCAnalyticsCustomers(siteID: siteID) } + let storedCustomers = storage.loadWCAnalyticsCustomers(siteID: siteID, matching: readOnlyCustomers.map { $0.customerID }) + let storedSearchResult: WCAnalyticsCustomerSearchResult? = { + guard let keyword else { + return nil + } + if let result = storage.loadWCAnalyticsCustomerSearchResult(siteID: siteID, keyword: keyword) { + return result + } else { + let result = storage.insertNewObject(ofType: Storage.WCAnalyticsCustomerSearchResult.self) + result.siteID = siteID + result.keyword = keyword + return result + } + }() readOnlyCustomers.forEach { - self?.upsertWCAnalyticsCustomer(siteID: siteID, readOnlyCustomer: $0, keyword: keyword, in: storage) + self?.upsertWCAnalyticsCustomer(siteID: siteID, + readOnlyCustomer: $0, + storedCustomers: storedCustomers, + storedSearchResult: storedSearchResult, + in: storage) } - } - - storageManager.saveDerivedType(derivedStorage: storage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Inserts or updates Customer entities into Storage /// - private func upsertCustomer(siteID: Int64, readOnlyCustomer: StorageCustomerConvertible, keyword: String? = nil, in storage: StorageType) { + private func upsertCustomer(siteID: Int64, + readOnlyCustomer: StorageCustomerConvertible, + storedCustomers: [Storage.Customer], + storedSearchResult: Storage.CustomerSearchResult?, + in storage: StorageType) { let storageCustomer: Storage.Customer = { // If the specific customerID for that siteID already exists, return it // If doesn't or the user is unregistered (loadingID == 0), insert a new one in Storage // Since we reset the customers everytime we request them, there's no risk of having duplicated unregistered customers if readOnlyCustomer.loadingID != 0, - let storedCustomer = storage.loadCustomer(siteID: siteID, customerID: readOnlyCustomer.loadingID) { + let storedCustomer = storedCustomers.first(where: { $0.customerID == readOnlyCustomer.loadingID }) { return storedCustomer } else { return storage.insertNewObject(ofType: Storage.Customer.self) } }() - if let keyword = keyword { - let storedSearchResult = self.sharedDerivedStorage.loadCustomerSearchResult(siteID: siteID, keyword: keyword) ?? - self.sharedDerivedStorage.insertNewObject(ofType: Storage.CustomerSearchResult.self) - - storedSearchResult.siteID = siteID - storedSearchResult.keyword = keyword - - storedSearchResult.addToCustomers(storageCustomer) - } - + storedSearchResult?.addToCustomers(storageCustomer) storageCustomer.update(with: readOnlyCustomer) } /// Inserts or update WCAnalyticsCustomer entities into Storage /// - private func upsertWCAnalyticsCustomer(siteID: Int64, readOnlyCustomer: WCAnalyticsCustomer, keyword: String? = nil, in storage: StorageType) { + private func upsertWCAnalyticsCustomer(siteID: Int64, + readOnlyCustomer: WCAnalyticsCustomer, + storedCustomers: [Storage.WCAnalyticsCustomer], + storedSearchResult: Storage.WCAnalyticsCustomerSearchResult?, + in storage: StorageType) { let storageCustomer: Storage.WCAnalyticsCustomer = { - if let storedCustomer = storage.loadWCAnalyticsCustomer(siteID: siteID, customerID: readOnlyCustomer.customerID) { + if let storedCustomer = storedCustomers.first(where: { $0.customerID == readOnlyCustomer.customerID }) { return storedCustomer } else { return storage.insertNewObject(ofType: Storage.WCAnalyticsCustomer.self) } }() - if let keyword { - let storedSearchResult = self.sharedDerivedStorage - .loadWCAnalyticsCustomerSearchResult(siteID: siteID, keyword: keyword) ?? - self.sharedDerivedStorage.insertNewObject(ofType: Storage.WCAnalyticsCustomerSearchResult.self) - - storedSearchResult.siteID = siteID - storedSearchResult.keyword = keyword - - storedSearchResult.addToCustomers(storageCustomer) - } - + storedSearchResult?.addToCustomers(storageCustomer) storageCustomer.update(with: readOnlyCustomer) } } diff --git a/Yosemite/Yosemite/Stores/DataStore.swift b/Yosemite/Yosemite/Stores/DataStore.swift index e2f5ede4674..fcb03e79225 100644 --- a/Yosemite/Yosemite/Stores/DataStore.swift +++ b/Yosemite/Yosemite/Stores/DataStore.swift @@ -8,12 +8,6 @@ import Storage public final class DataStore: Store { private let remote: DataRemote - /// Shared private StorageType for use during then entire DataStore sync process - /// - private lazy var sharedDerivedStorage: StorageType = { - storageManager.writerDerivedStorage - }() - public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { self.remote = DataRemote(network: network) super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -71,14 +65,9 @@ private extension DataStore { /// `onCompletion` will be called on the main thread! /// func insertCountriesInBackground(countries: [Country], onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { [weak self] in - self?.insertCountries(countries: countries, in: derivedStorage) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + self?.insertCountries(countries: countries, in: storage) + }, completion: onCompletion, on: .main) } /// Delete and re-inserts the specified readonly Country entities in the current thread. diff --git a/Yosemite/Yosemite/Stores/GoogleAdsStore.swift b/Yosemite/Yosemite/Stores/GoogleAdsStore.swift index df96c10e4b6..ebc927a7e63 100644 --- a/Yosemite/Yosemite/Stores/GoogleAdsStore.swift +++ b/Yosemite/Yosemite/Stores/GoogleAdsStore.swift @@ -7,10 +7,6 @@ import Storage public final class GoogleAdsStore: Store { private let remote: GoogleListingsAndAdsRemoteProtocol - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network, diff --git a/Yosemite/Yosemite/Stores/InboxNotesStore.swift b/Yosemite/Yosemite/Stores/InboxNotesStore.swift index b5641586039..5c71422aa1b 100644 --- a/Yosemite/Yosemite/Stores/InboxNotesStore.swift +++ b/Yosemite/Yosemite/Stores/InboxNotesStore.swift @@ -7,10 +7,6 @@ import Networking public class InboxNotesStore: Store { private let remote: InboxNotesRemote - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { self.remote = InboxNotesRemote(network: network) super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -165,21 +161,16 @@ private extension InboxNotesStore { siteID: Int64, shouldDeleteExistingNotes: Bool, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { [weak self] in + storageManager.performAndSave({ [weak self] storage in guard let self = self else { return } if shouldDeleteExistingNotes { - self.deleteStoredInboxNotes(siteID: siteID, in: derivedStorage) + self.deleteStoredInboxNotes(siteID: siteID, in: storage) } self.upsertStoredInboxNotes(readOnlyInboxNotes: readOnlyInboxNotes, - in: derivedStorage, + in: storage, siteID: siteID) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Updates or Inserts the specified Inbox Note entities @@ -219,14 +210,9 @@ private extension InboxNotesStore { func deleteStoredInboxNoteInBackground(siteID: Int64, noteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - derivedStorage.deleteInboxNote(siteID: siteID, id: noteID) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ storage in + storage.deleteInboxNote(siteID: siteID, id: noteID) + }, completion: onCompletion, on: .main) } /// Deletes all Inbox Notes Entities in a background thread @@ -234,14 +220,9 @@ private extension InboxNotesStore { /// func deleteStoredInboxNotesInBackground(siteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - derivedStorage.deleteInboxNotes(siteID: siteID) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ storage in + storage.deleteInboxNotes(siteID: siteID) + }, completion: onCompletion, on: .main) } /// Deletes all Storage.InboxNote with the specified `siteID` diff --git a/Yosemite/Yosemite/Stores/MediaStore.swift b/Yosemite/Yosemite/Stores/MediaStore.swift index 14e84cacc14..7c0f7413b11 100644 --- a/Yosemite/Yosemite/Stores/MediaStore.swift +++ b/Yosemite/Yosemite/Stores/MediaStore.swift @@ -101,22 +101,12 @@ private extension MediaStore { pageNumber: Int, pageSize: Int, onCompletion: @escaping (Result<[Media], Error>) -> Void) { - if isLoggedInWithoutWPCOMCredentials(siteID) || isSiteJetpackJCPConnected(siteID) { - remote.loadMediaLibraryFromWordPressSite(siteID: siteID, - productID: productID, - imagesOnly: imagesOnly, - pageNumber: pageNumber, - pageSize: pageSize) { result in - onCompletion(result.map { $0.map { $0.toMedia() } }) - } - } else { - remote.loadMediaLibrary(for: siteID, - productID: productID, - imagesOnly: imagesOnly, - pageNumber: pageNumber, - pageSize: pageSize, - context: nil, - completion: onCompletion) + remote.loadMediaLibrary(siteID: siteID, + productID: productID, + imagesOnly: imagesOnly, + pageNumber: pageNumber, + pageSize: pageSize) { result in + onCompletion(result.map { $0.map { $0.toMedia() } }) } } @@ -196,12 +186,8 @@ private extension MediaStore { productID: Int64, mediaID: Int64, onCompletion: @escaping (Result) -> Void) { - if isLoggedInWithoutWPCOMCredentials(siteID) || isSiteJetpackJCPConnected(siteID) { - remote.updateProductIDToWordPressSite(siteID: siteID, productID: productID, mediaID: mediaID) { result in - onCompletion(result.map { $0.toMedia() }) - } - } else { - remote.updateProductID(siteID: siteID, productID: productID, mediaID: mediaID, completion: onCompletion) + remote.updateProductID(siteID: siteID, productID: productID, mediaID: mediaID) { result in + onCompletion(result.map { $0.toMedia() }) } } } diff --git a/Yosemite/Yosemite/Stores/MetaDataStore.swift b/Yosemite/Yosemite/Stores/MetaDataStore.swift index 8d7e353edc8..c392155244b 100644 --- a/Yosemite/Yosemite/Stores/MetaDataStore.swift +++ b/Yosemite/Yosemite/Stores/MetaDataStore.swift @@ -8,10 +8,6 @@ import Storage public final class MetaDataStore: Store { private let remote: MetaDataRemoteProtocol - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network, @@ -116,25 +112,24 @@ private extension MetaDataStore { orderID: Int64, siteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - - derivedStorage.perform { - let storedMetaData = derivedStorage.loadOrderMetaData(siteID: siteID, orderID: orderID) + storageManager.performAndSave({ [weak self] storage in + guard let self else { return } + let storedMetaData = storage.loadOrderMetaData(siteID: siteID, orderID: orderID) + guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { + DDLogWarn("⚠️ Could not persist the Order MetaData — unable to retrieve stored order with ID \(orderID).") + return + } for readOnlyOrderMetaData in readOnlyOrderMetaDatas { - self.saveMetaData(derivedStorage, readOnlyOrderMetaData, storedMetaData, orderID: orderID, siteID: siteID) + saveMetaData(storage, readOnlyOrderMetaData, storedMetaData, storageOrder: storageOrder) } storedMetaData.forEach { storedMetaData in if !readOnlyOrderMetaDatas.contains(where: { $0.metadataID == storedMetaData.metadataID }) { - derivedStorage.deleteObject(storedMetaData) + storage.deleteObject(storedMetaData) } } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Using the provided StorageType, update or insert a Storage.MetaData using the provided ReadOnly @@ -145,17 +140,12 @@ private extension MetaDataStore { /// - orderID: ID of the order. /// - siteID: Site id of the order. /// - func saveMetaData(_ storage: StorageType, _ readOnlyMetaData: MetaData, _ storedMetaData: [Storage.MetaData], orderID: Int64, siteID: Int64) { + func saveMetaData(_ storage: StorageType, _ readOnlyMetaData: MetaData, _ storedMetaData: [Storage.MetaData], storageOrder: Storage.Order) { if let existingStorageMetaData = storedMetaData.first(where: { $0.metadataID == readOnlyMetaData.metadataID }) { existingStorageMetaData.update(with: readOnlyMetaData) return } - guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { - DDLogWarn("⚠️ Could not persist the Order MetaData with ID \(readOnlyMetaData.metadataID) — unable to retrieve stored order with ID \(orderID).") - return - } - let newStorageMetaData = storage.insertNewObject(ofType: Storage.MetaData.self) newStorageMetaData.update(with: readOnlyMetaData) newStorageMetaData.order = storageOrder @@ -196,24 +186,25 @@ private extension MetaDataStore { productID: Int64, siteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - let storedMetaData = derivedStorage.loadProductMetaData(siteID: siteID, productID: productID) + storageManager.performAndSave({ [weak self] storage in + guard let self else { return } + let storedMetaData = storage.loadProductMetaData(siteID: siteID, productID: productID) + + guard let storageProduct = storage.loadProduct(siteID: siteID, productID: productID) else { + DDLogWarn("⚠️ Could not persist the Product MetaData — unable to retrieve stored product with ID \(productID).") + return + } for readOnlyProductMetaData in readOnlyProductMetaDatas { - self.saveMetaData(derivedStorage, readOnlyProductMetaData, storedMetaData, productID: productID, siteID: siteID) + saveMetaData(storage, readOnlyProductMetaData, storedMetaData, storageProduct: storageProduct) } storedMetaData.forEach { storedMetaData in if !readOnlyProductMetaDatas.contains(where: { $0.metadataID == storedMetaData.metadataID }) { - derivedStorage.deleteObject(storedMetaData) + storage.deleteObject(storedMetaData) } } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Using the provided StorageType, update or insert a Storage.MetaData using the provided ReadOnly @@ -224,20 +215,15 @@ private extension MetaDataStore { /// - productID: ID of the product. /// - siteID: Site id of the product. /// - func saveMetaData(_ storage: StorageType, _ readOnlyMetaData: MetaData, _ storedMetaData: [Storage.MetaData], productID: Int64, siteID: Int64) { + func saveMetaData(_ storage: StorageType, _ readOnlyMetaData: MetaData, _ storedMetaData: [Storage.MetaData], storageProduct: Storage.Product) { if let existingStorageMetaData = storedMetaData.first(where: { $0.metadataID == readOnlyMetaData.metadataID }) { existingStorageMetaData.update(with: readOnlyMetaData) return } - guard let storageOrder = storage.loadProduct(siteID: siteID, productID: productID) else { - DDLogWarn("⚠️ Could not persist the Product MetaData with ID \(readOnlyMetaData.metadataID) — unable to retrieve stored product with ID \(productID).") - return - } - let newStorageMetaData = storage.insertNewObject(ofType: Storage.MetaData.self) newStorageMetaData.update(with: readOnlyMetaData) - newStorageMetaData.product = storageOrder - storageOrder.addToCustomFields(newStorageMetaData) + newStorageMetaData.product = storageProduct + storageProduct.addToCustomFields(newStorageMetaData) } } diff --git a/Yosemite/Yosemite/Stores/NotificationStore.swift b/Yosemite/Yosemite/Stores/NotificationStore.swift index 196ab97c461..10bdd965894 100644 --- a/Yosemite/Yosemite/Stores/NotificationStore.swift +++ b/Yosemite/Yosemite/Stores/NotificationStore.swift @@ -9,10 +9,6 @@ public class NotificationStore: Store { private let remote: NotificationsRemote private let devicesRemote: DevicesRemote - /// Thread Safety - /// - private static let lock = NSLock() - /// Shared private StorageType for use during then entire notification sync process /// private static var privateStorage: StorageType! @@ -101,30 +97,23 @@ private extension NotificationStore { return } - self?.deleteLocalMissingNotes(from: hashes) { [weak self] in + self?.deleteLocalMissingNotes(from: hashes) { [weak self] outdatedIDs in - self?.determineUpdatedNotes(using: hashes) { [weak self] outdatedIDs in - guard let self = self else { - return - } + guard outdatedIDs.isEmpty == false else { + onCompletion(nil) + return + } - guard outdatedIDs.isEmpty == false else { - onCompletion(nil) + self?.remote.loadNotes(noteIDs: outdatedIDs, pageSize: Constants.maximumPageSize) { result in + guard let self = self else { return } - - self.remote.loadNotes(noteIDs: outdatedIDs, pageSize: Constants.maximumPageSize) { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .failure(let error): - onCompletion(error) - case .success(let notes): - self.updateLocalNotes(with: notes) { - onCompletion(nil) - } + switch result { + case .failure(let error): + onCompletion(error) + case .success(let notes): + self.updateLocalNotes(with: notes) { + onCompletion(nil) } } } @@ -170,32 +159,22 @@ private extension NotificationStore { func updateReadStatus(for noteIDs: [Int64], read: Bool, onCompletion: @escaping (Error?) -> Void) { /// Optimistically Update /// - updateLocalNoteReadStatus(for: noteIDs, read: read) + updateLocalNoteReadStatus(for: noteIDs, read: read) { [weak self] in - /// On error we'll just mark the Note for Refresh - /// - remote.updateReadStatus(noteIDs: noteIDs, read: read) { [weak self] error in - guard let self = self else { - return - } - - guard let error = error else { + /// On error we'll just mark the Note for Refresh + /// + self?.remote.updateReadStatus(noteIDs: noteIDs, read: read) { [weak self] error in + guard let self else { + return onCompletion(error) + } - /// What is this about: - /// Notice that there are few conditions in which the Network Request's callback may run *before* - /// the Optimisitc Update, such as in Unit Tests. - /// This may cause the Callback to run before the Read Flag has been toggled, which isn't cool. - /// *FORGIVE ME*, this is a workaround: the onCompletion closure must run after a No-OP in the derived - /// storage. - /// - self.performSharedDerivedStorageNoOp { + if let error { + invalidateCache(for: noteIDs) { + onCompletion(error) + } + } else { onCompletion(nil) } - return - } - - self.invalidateCache(for: noteIDs) { - onCompletion(error) } } } @@ -215,29 +194,32 @@ private extension NotificationStore { // extension NotificationStore { - /// Deletes the collection of local notifications that cannot be found in a given collection of - /// remote hashes. + /// Deletes the collection of local notifications that cannot be found in a given collection of remote hashes. /// - /// - Parameter remoteIds: Collection of remote Note IDs. + /// - Parameters: + /// - hashes: Collection of remote hashes to compare local notifications with. + /// - completion: Callback closure returning outdated note IDs. /// - func deleteLocalMissingNotes(from hashes: [NoteHash], completion: @escaping (() -> Void)) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { + func deleteLocalMissingNotes(from hashes: [NoteHash], completion: @escaping (([Int64]) -> Void)) { + storageManager.performAndSave({ [weak self] storage -> [Int64] in + guard let self else { return [] } // The beauty of threadsafe Immutable Entities!! let remoteIDs = hashes.map { $0.noteID } let predicate = NSPredicate(format: "NOT (noteID IN %@)", remoteIDs) - for orphan in derivedStorage.allObjects(ofType: Storage.Note.self, matching: predicate, sortedBy: nil) { - derivedStorage.deleteObject(orphan) + let allObjects = storage.allObjects(ofType: Storage.Note.self, matching: predicate, sortedBy: nil) + for orphan in allObjects { + storage.deleteObject(orphan) } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async { - completion() + return determineOutdatedNotes(using: hashes, in: storage) + }, completion: { result in + switch result { + case .success(let outdatedNoteIDs): + completion(outdatedNoteIDs) + case .failure: // This case should not happen as no error is thrown above + completion([]) } - } + }, on: .main) } /// Given a collection of Notes, this method will insert missing local ones, and update the others that can be found. @@ -247,149 +229,68 @@ extension NotificationStore { /// - completion: Callback to be executed on completion /// func updateLocalNotes(with remoteNotes: [Note], onCompletion: (() -> Void)? = nil) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { + storageManager.performAndSave({ storage in for remoteNote in remoteNotes { - let localNote = derivedStorage.loadNotification(noteID: remoteNote.noteID) ?? derivedStorage.insertNewObject(ofType: Storage.Note.self) + let localNote = storage.loadNotification(noteID: remoteNote.noteID) ?? storage.insertNewObject(ofType: Storage.Note.self) localNote.update(with: remoteNote) } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - guard let onCompletion = onCompletion else { - return - } - - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Updates the read status for the specified Notifications. The callback happens on the Main Thread. /// - func updateLocalNoteReadStatus(for noteIDs: [Int64], read: Bool, onCompletion: (() -> Void)? = nil) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { - let notifications = noteIDs.compactMap { derivedStorage.loadNotification(noteID: $0) } + func updateLocalNoteReadStatus(for noteIDs: [Int64], read: Bool, onCompletion: @escaping (() -> Void)) { + storageManager.performAndSave({ storage in + let notifications = noteIDs.compactMap { storage.loadNotification(noteID: $0) } for note in notifications { note.read = read } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - guard let onCompletion = onCompletion else { - return - } - - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Given a collection of NoteHash Entities, this method will determine the `.noteID`'s of those entities that /// are either not locally found, or got their `.hash` field outdated. /// - func determineUpdatedNotes(using hashes: [NoteHash], completion: @escaping ([Int64]) -> Void) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { - let remoteIds = hashes.map { $0.noteID } - let predicate = NSPredicate(format: "noteID IN %@", remoteIds) - var localHashes = [Int64: Int64]() + func determineOutdatedNotes(using hashes: [NoteHash], in storage: StorageType) -> [Int64] { - for note in derivedStorage.allObjects(ofType: StorageNote.self, matching: predicate, sortedBy: nil) { - localHashes[note.noteID] = Int64(note.noteHash) - } - - let outdated = hashes.filter { remote in - let localHash = localHashes[remote.noteID] - return localHash == nil || localHash != remote.hash - } + let remoteIds = hashes.map { $0.noteID } + let predicate = NSPredicate(format: "noteID IN %@", remoteIds) + var localHashes = [Int64: Int64]() - let outdatedIds = outdated.map { $0.noteID } + for note in storage.allObjects(ofType: StorageNote.self, matching: predicate, sortedBy: nil) { + localHashes[note.noteID] = Int64(note.noteHash) + } - DispatchQueue.main.async { - completion(outdatedIds) - } + let outdated = hashes.filter { remote in + let localHash = localHashes[remote.noteID] + return localHash == nil || localHash != remote.hash } + + let outdatedIds = outdated.map { $0.noteID } + return outdatedIds } /// Invalidates the Hash for the specified Notifications. /// func invalidateCache(for noteIDs: [Int64], onCompletion: (() -> Void)? = nil) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { - let notifications = noteIDs.compactMap { derivedStorage.loadNotification(noteID: $0) } + storageManager.performAndSave({ storage in + let notifications = noteIDs.compactMap { storage.loadNotification(noteID: $0) } for note in notifications { note.noteHash = Int64.min } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - guard let onCompletion = onCompletion else { - return - } - - DispatchQueue.main.async(execute: onCompletion) - } - } - - /// Runs a No-OP in the Shared Derived Storage. On completion, the callback will be executed on the main thread. - /// - func performSharedDerivedStorageNoOp(onCompletion: @escaping () -> Void) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Updates the deletion "status" for the specified Notification. The callback happens on the Main Thread. /// func markLocalNoteAsDeleted(for noteID: Int64, isDeleted: Bool, onCompletion: (() -> Void)? = nil) { - let derivedStorage = type(of: self).sharedDerivedStorage(with: storageManager) - - derivedStorage.perform { - let notification = derivedStorage.loadNotification(noteID: noteID) + storageManager.performAndSave({ storage in + let notification = storage.loadNotification(noteID: noteID) notification?.deleteInProgress = isDeleted - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - guard let onCompletion = onCompletion else { - return - } - - DispatchQueue.main.async(execute: onCompletion) - } - } -} - - -// MARK: - Thread Safety Helpers -// -extension NotificationStore { - /// Returns the current shared derived StorageType, if any. Otherwise proceeds to create a new - /// derived StorageType, given a specified StorageManagerType. - /// - static func sharedDerivedStorage(with manager: StorageManagerType) -> StorageType { - lock.lock() - if privateStorage == nil { - privateStorage = manager.writerDerivedStorage - } - lock.unlock() - - return privateStorage - } - - /// Nukes the private Shared Derived Storage instance. - /// - static func resetSharedDerivedStorage() { - privateStorage = nil + }, completion: onCompletion, on: .main) } } - // MARK: - Constants! // extension NotificationStore { diff --git a/Yosemite/Yosemite/Stores/Order/OrderSyncProductInput.swift b/Yosemite/Yosemite/Stores/Order/OrderSyncProductInput.swift index f5589803681..1aade5e13e9 100644 --- a/Yosemite/Yosemite/Stores/Order/OrderSyncProductInput.swift +++ b/Yosemite/Yosemite/Stores/Order/OrderSyncProductInput.swift @@ -18,6 +18,23 @@ public extension OrderSyncProductTypeProtocol where Self: Equatable { } } +public protocol OrderSyncProductVariationTypeProtocol: Hashable { + var price: String { get } + var productVariationID: Int64 { get } + var productID: Int64 { get } + + func isEqual(to: any OrderSyncProductVariationTypeProtocol) -> Bool +} + +extension ProductVariation: OrderSyncProductVariationTypeProtocol {} + +public extension OrderSyncProductVariationTypeProtocol where Self: Equatable { + func isEqual(to other: any OrderSyncProductVariationTypeProtocol) -> Bool { + guard let other = other as? Self else { return false } + return self == other + } +} + /// Product input for an `OrderSynchronizer` type. /// public struct OrderSyncProductInput { @@ -39,7 +56,7 @@ public struct OrderSyncProductInput { /// public enum ProductType: Hashable { case product(any OrderSyncProductTypeProtocol) - case variation(ProductVariation) + case variation(any OrderSyncProductVariationTypeProtocol) public func hash(into hasher: inout Hasher) { switch self { @@ -57,7 +74,7 @@ public struct OrderSyncProductInput { case (.product(let lhsProduct), .product(let rhsProduct)): return lhsProduct.isEqual(to: rhsProduct) case (.variation(let lhsVariation), .variation(let rhsVariation)): - return lhsVariation == rhsVariation + return lhsVariation.isEqual(to: rhsVariation) default: return false } diff --git a/Yosemite/Yosemite/Stores/OrderNoteStore.swift b/Yosemite/Yosemite/Stores/OrderNoteStore.swift index 8364c6825c4..ade0953322b 100644 --- a/Yosemite/Yosemite/Stores/OrderNoteStore.swift +++ b/Yosemite/Yosemite/Stores/OrderNoteStore.swift @@ -7,12 +7,6 @@ import Storage public class OrderNoteStore: Store { private let remote: OrdersRemote - /// Shared private StorageType for use during then entire OrderNotes sync process - /// - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { self.remote = OrdersRemote(network: network) super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -85,45 +79,38 @@ extension OrderNoteStore { /// Updates (OR Inserts) the specified ReadOnly OrderNote Entity into the Storage Layer. /// func upsertStoredOrderNoteInBackground(readOnlyOrderNote: Networking.OrderNote, orderID: Int64, siteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - self.saveNote(derivedStorage, readOnlyOrderNote, orderID, siteID: siteID) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { + DDLogWarn("⚠️ Could not persist the OrderNote with ID \(readOnlyOrderNote.noteID) — unable to retrieve stored order with ID \(orderID).") + return + } + self?.saveNote(storage, readOnlyOrderNote, storageOrder, siteID: siteID) + }, completion: onCompletion, on: .main) } /// Updates (OR Inserts) the specified ReadOnly OrderNote Entities into the Storage Layer. /// func upsertStoredOrderNotesInBackground(readOnlyOrderNotes: [Networking.OrderNote], orderID: Int64, siteID: Int64, onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { + storageManager.performAndSave({ [weak self] storage in + guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { + DDLogWarn("⚠️ Could not persist the OrderNotes — unable to retrieve stored order with ID \(orderID).") + return + } for readOnlyOrderNote in readOnlyOrderNotes { - self.saveNote(derivedStorage, readOnlyOrderNote, orderID, siteID: siteID) + self?.saveNote(storage, readOnlyOrderNote, storageOrder, siteID: siteID) } - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + }, completion: onCompletion, on: .main) } /// Using the provided StorageType, update or insert a Storage.OrderNote using the provided ReadOnly /// OrderNote. This func does *not* persist any unsaved changes to storage. /// - private func saveNote(_ storage: StorageType, _ readOnlyOrderNote: OrderNote, _ orderID: Int64, siteID: Int64) { - if let existingStorageNote = storage.loadOrderNote(noteID: readOnlyOrderNote.noteID) { + private func saveNote(_ storage: StorageType, _ readOnlyOrderNote: OrderNote, _ storageOrder: StorageOrder, siteID: Int64) { + if let existingStorageNote = storageOrder.notes?.first(where: { $0.noteID == readOnlyOrderNote.noteID }) { existingStorageNote.update(with: readOnlyOrderNote) return } - guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { - DDLogWarn("⚠️ Could not persist the OrderNote with ID \(readOnlyOrderNote.noteID) — unable to retrieve stored order with ID \(orderID).") - return - } - let newStorageNote = storage.insertNewObject(ofType: Storage.OrderNote.self) newStorageNote.update(with: readOnlyOrderNote) newStorageNote.order = storageOrder diff --git a/Yosemite/Yosemite/Stores/PaymentGatewayStore.swift b/Yosemite/Yosemite/Stores/PaymentGatewayStore.swift index 35e2817717e..ee53bd9bf4b 100644 --- a/Yosemite/Yosemite/Stores/PaymentGatewayStore.swift +++ b/Yosemite/Yosemite/Stores/PaymentGatewayStore.swift @@ -8,12 +8,6 @@ public final class PaymentGatewayStore: Store { private let remote: PaymentGatewayRemote - /// Shared private StorageType for use during then entire Orders sync process - /// - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { self.remote = PaymentGatewayRemote(network: network) super.init(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -89,31 +83,25 @@ private extension PaymentGatewayStore { /// *in a background thread*. `onCompletion` will be called on the main thread! /// func upsertPaymentGatewaysInBackground(siteID: Int64, paymentGateways: [PaymentGateway], onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { [weak self] in - self?.upsertPaymentGateways(siteID: siteID, paymentGateways: paymentGateways) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + self?.upsertPaymentGateways(siteID: siteID, paymentGateways: paymentGateways, using: storage) + }, completion: onCompletion, on: .main) } /// Updates (OR Inserts) the specified ReadOnly Payment Gateways Entities in the current thread /// - func upsertPaymentGateways(siteID: Int64, paymentGateways: [PaymentGateway]) { - let derivedStorage = sharedDerivedStorage + func upsertPaymentGateways(siteID: Int64, paymentGateways: [PaymentGateway], using storage: StorageType) { + let storedGateways = storage.loadAllPaymentGateways(siteID: siteID) for gateway in paymentGateways { - let storageGateway = derivedStorage.loadPaymentGateway(siteID: gateway.siteID, gatewayID: gateway.gatewayID) ?? - derivedStorage.insertNewObject(ofType: Storage.PaymentGateway.self) + let storageGateway = storedGateways.first(where: { $0.gatewayID == gateway.gatewayID }) ?? + storage.insertNewObject(ofType: Storage.PaymentGateway.self) storageGateway.update(with: gateway) } // Now, remove any objects that exist in storage but not in paymentGateways - let storedGateways = derivedStorage.loadAllPaymentGateways(siteID: siteID) storedGateways.forEach { storedGateway in if !paymentGateways.contains(where: { $0.gatewayID == storedGateway.gatewayID }) { - derivedStorage.deleteObject(storedGateway) + storage.deleteObject(storedGateway) } } } diff --git a/Yosemite/Yosemite/Stores/ProductStore.swift b/Yosemite/Yosemite/Stores/ProductStore.swift index 5b77a992a2e..2e82306b272 100644 --- a/Yosemite/Yosemite/Stores/ProductStore.swift +++ b/Yosemite/Yosemite/Stores/ProductStore.swift @@ -422,11 +422,10 @@ private extension ProductStore { return onCompletion(.failure(ProductLoadError.emptyIdentifier)) } - searchProductsByIdentifier(siteID: siteID, - keyword: identifier, - completion: { result in - switch result { - case let .success((products, source)): + Task { + do { + let (products, source) = try await searchProductsByIdentifier(for: siteID, keyword: identifier) + let matchedProducts = products.filter { $0.sku == identifier || $0.globalUniqueID == identifier } guard !matchedProducts.isEmpty else { @@ -449,10 +448,10 @@ private extension ProductStore { onCompletion(.success((.product(product), source))) }) } - case let .failure(error): + } catch { onCompletion(.failure(error)) } - }) + } } /// Adds a product. @@ -1279,37 +1278,59 @@ private extension ProductStore { } private extension ProductStore { - func searchProductsByIdentifier(siteID: Int64, keyword: String, completion: @escaping (Result<([Product], ItemIdentifierSearchResultSource), Error>) -> Void) { - remote.searchProductsBySKU(for: siteID, - keyword: keyword, - pageNumber: Remote.Default.firstPageNumber, - pageSize: ProductsRemote.Default.pageSize, - completion: { [weak self] result in - var returningResults: [Product] = [] - switch result { - case let .success(products): - returningResults = products - case .failure: - break - } + func searchProductsByIdentifier(for siteID: Int64, keyword: String) async throws -> ([Product], ItemIdentifierSearchResultSource) { + async let skuProducts = searchProductsBySKU(for: siteID, keyword: keyword) + async let globalUniqueIdentifierProducts = searchProductsByGlobalUniqueIdentifier(for: siteID, keyword: keyword) - if returningResults.isEmpty { - self?.remote.searchProductsByGlobalUniqueIdentifier(for: siteID, - keyword: keyword, - pageNumber: Remote.Default.firstPageNumber, - pageSize: ProductsRemote.Default.pageSize, - completion: { result in - switch result { - case let .success(products): - completion(.success((products, .globalUniqueIdentifier))) - case .failure(let error): - completion(.failure(error)) - } - }) + do { + let globalUniqueIdentifierResult = try await globalUniqueIdentifierProducts + + if !(try await globalUniqueIdentifierProducts.isEmpty) { + return (globalUniqueIdentifierResult, .globalUniqueIdentifier) } else { - completion(.success((returningResults, .SKU))) + let skuResult = try await skuProducts + + if !(try await skuProducts.isEmpty) { + return (skuResult, .SKU) + } else { + throw ProductLoadError.notFound + } } - }) + } catch { + throw(error) + } + } + + func searchProductsBySKU(for siteID: Int64, keyword: String) async throws -> [Product] { + try await withCheckedThrowingContinuation { continuation in + remote.searchProductsBySKU(for: siteID, + keyword: keyword, + pageNumber: Remote.Default.firstPageNumber, + pageSize: ProductsRemote.Default.pageSize) { result in + switch result { + case let .success(products): + continuation.resume(returning: products) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } + } + + func searchProductsByGlobalUniqueIdentifier(for siteID: Int64, keyword: String) async throws -> [Product] { + try await withCheckedThrowingContinuation { continuation in + remote.searchProductsByGlobalUniqueIdentifier(for: siteID, + keyword: keyword, + pageNumber: Remote.Default.firstPageNumber, + pageSize: ProductsRemote.Default.pageSize) { result in + switch result { + case let .success(products): + continuation.resume(returning: products) + case let .failure(error): + continuation.resume(throwing: error) + } + } + } } } diff --git a/Yosemite/Yosemite/Stores/ProductVariationStore.swift b/Yosemite/Yosemite/Stores/ProductVariationStore.swift index 57884657d89..f349e7b6275 100644 --- a/Yosemite/Yosemite/Stores/ProductVariationStore.swift +++ b/Yosemite/Yosemite/Stores/ProductVariationStore.swift @@ -8,10 +8,6 @@ public final class ProductVariationStore: Store { private let remote: ProductVariationsRemoteProtocol private let productVariationStorageManager: ProductVariationStorageManager - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - public override convenience init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { let remote = ProductVariationsRemote(network: network) self.init(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) diff --git a/Yosemite/Yosemite/Stores/ReceiptStore.swift b/Yosemite/Yosemite/Stores/ReceiptStore.swift index ab7c3e4800c..f800294d61d 100644 --- a/Yosemite/Yosemite/Stores/ReceiptStore.swift +++ b/Yosemite/Yosemite/Stores/ReceiptStore.swift @@ -11,10 +11,6 @@ public class ReceiptStore: Store { private let fileStorage: FileStorage private let remote: ReceiptRemote - private lazy var sharedDerivedStorage: StorageType = { - storageManager.writerDerivedStorage - }() - private lazy var currencyFormatter: CurrencyFormatter = { CurrencyFormatter(currencySettings: CurrencySettings()) }() diff --git a/Yosemite/Yosemite/Stores/SettingStore.swift b/Yosemite/Yosemite/Stores/SettingStore.swift index 46ab97b43d2..04f5f13bad2 100644 --- a/Yosemite/Yosemite/Stores/SettingStore.swift +++ b/Yosemite/Yosemite/Stores/SettingStore.swift @@ -9,10 +9,6 @@ public class SettingStore: Store { private let siteSettingsRemote: SiteSettingsRemote private let siteAPIRemote: SiteAPIRemote - private lazy var sharedDerivedStorage: StorageType = { - return storageManager.writerDerivedStorage - }() - public override init(dispatcher: Dispatcher, storageManager: StorageManagerType, network: Network) { self.siteSettingsRemote = SiteSettingsRemote(network: network) self.siteAPIRemote = SiteAPIRemote(network: network) @@ -196,48 +192,34 @@ private extension SettingStore { /// on the main thread! /// func upsertStoredGeneralSettingsInBackground(siteID: Int64, readOnlySiteSettings: [Networking.SiteSetting], onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - self.upsertSettings(readOnlySiteSettings, in: derivedStorage, siteID: siteID, settingGroup: SiteSettingGroup.general) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + self?.upsertSettings(readOnlySiteSettings, in: storage, siteID: siteID, settingGroup: SiteSettingGroup.general) + }, completion: onCompletion, on: .main) } /// Updates (OR Inserts) the specified **product** ReadOnly `SiteSetting` entities **in a background thread**. `onCompletion` will be called /// on the main thread! /// func upsertStoredProductSettingsInBackground(siteID: Int64, readOnlySiteSettings: [Networking.SiteSetting], onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - self.upsertSettings(readOnlySiteSettings, in: derivedStorage, siteID: siteID, settingGroup: SiteSettingGroup.product) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + self?.upsertSettings(readOnlySiteSettings, in: storage, siteID: siteID, settingGroup: SiteSettingGroup.product) + }, completion: onCompletion, on: .main) } /// Updates (OR Inserts) the specified **advanced** ReadOnly `SiteSetting` entities **in a background thread**. `onCompletion` will be called /// on the main thread! /// func upsertStoredAdvancedSettingsInBackground(siteID: Int64, readOnlySiteSettings: [Networking.SiteSetting], onCompletion: @escaping () -> Void) { - let derivedStorage = sharedDerivedStorage - derivedStorage.perform { - self.upsertSettings(readOnlySiteSettings, in: derivedStorage, siteID: siteID, settingGroup: SiteSettingGroup.advanced) - } - - storageManager.saveDerivedType(derivedStorage: derivedStorage) { - DispatchQueue.main.async(execute: onCompletion) - } + storageManager.performAndSave({ [weak self] storage in + self?.upsertSettings(readOnlySiteSettings, in: storage, siteID: siteID, settingGroup: SiteSettingGroup.advanced) + }, completion: onCompletion, on: .main) } func upsertSettings(_ readOnlySiteSettings: [SiteSetting], in storage: StorageType, siteID: Int64, settingGroup: SiteSettingGroup) { + let storageSiteSettings = storage.loadSiteSettings(siteID: siteID, settingGroupKey: settingGroup.rawValue) // Upsert the settings from the read-only site settings for readOnlyItem in readOnlySiteSettings { - if let existingStorageItem = storage.loadSiteSetting(siteID: siteID, settingID: readOnlyItem.settingID) { + if let existingStorageItem = storageSiteSettings?.first(where: { $0.settingID == readOnlyItem.settingID }) { existingStorageItem.update(with: readOnlyItem) } else { let newStorageItem = storage.insertNewObject(ofType: Storage.SiteSetting.self) @@ -246,7 +228,7 @@ private extension SettingStore { } // Now, remove any objects that exist in storageSiteSettings but not in readOnlySiteSettings - if let storageSiteSettings = storage.loadSiteSettings(siteID: siteID, settingGroupKey: settingGroup.rawValue) { + if let storageSiteSettings { storageSiteSettings.forEach({ storageItem in if readOnlySiteSettings.first(where: { $0.settingID == storageItem.settingID } ) == nil { storage.deleteObject(storageItem) diff --git a/Yosemite/Yosemite/Stores/SystemStatusStore.swift b/Yosemite/Yosemite/Stores/SystemStatusStore.swift index 31a1248f74f..487eda60f1d 100644 --- a/Yosemite/Yosemite/Stores/SystemStatusStore.swift +++ b/Yosemite/Yosemite/Stores/SystemStatusStore.swift @@ -63,7 +63,7 @@ private extension SystemStatusStore { } } - func fetchSystemStatusReport(siteID: Int64, completionHandler: @escaping (Result) -> Void) { + func fetchSystemStatusReport(siteID: Int64, completionHandler: @escaping (Result) -> Void) { remote.fetchSystemStatusReport(for: siteID, completion: completionHandler) } } diff --git a/Yosemite/Yosemite/Stores/WooShippingStore.swift b/Yosemite/Yosemite/Stores/WooShippingStore.swift index 695af2f4077..62e3b8d47f1 100644 --- a/Yosemite/Yosemite/Stores/WooShippingStore.swift +++ b/Yosemite/Yosemite/Stores/WooShippingStore.swift @@ -32,6 +32,8 @@ public final class WooShippingStore: Store { switch action { case .createPackage(let siteID, let customPackage, let predefinedOption, let completion): createPackage(siteID: siteID, customPackage: customPackage, predefinedOption: predefinedOption, completion: completion) + case .deletePackage(let siteID, let packageID, let completion): + deletePackage(siteID: siteID, packageID: packageID, completion: completion) case .loadLabelRates(let siteID, let orderID, let originAddress, let destinationAddress, let packages, let completion): loadLabelRates(siteID: siteID, orderID: orderID, @@ -63,6 +65,8 @@ public final class WooShippingStore: Store { completion: completion) case let .printLabel(siteID, labelIDs, paperSize, completion): printLabel(siteID: siteID, labelIDs: labelIDs, paperSize: paperSize, completion: completion) + case .loadOriginAddresses(let siteID, let completion): + loadOriginAddresses(siteID: siteID, completion: completion) } } } @@ -72,16 +76,33 @@ private extension WooShippingStore { customPackage: WooShippingCustomPackage? = nil, predefinedOption: WooShippingPredefinedSavedOption? = nil, completion: @escaping (Result) -> Void) { - remote.createPackage(siteID: siteID, customPackage: customPackage, predefinedOption: predefinedOption) { result in + remote.createPackage(siteID: siteID, customPackage: customPackage, predefinedOption: predefinedOption) { [weak self] result in switch result { case .success(let packages): - completion(.success(packages)) + self?.upsertCreatePackagesResponseInBackground(readOnlyPackages: packages, siteID: siteID, onCompletion: { + completion(.success(packages)) + }) case .failure(let error): completion(.failure(PackageCreationError(error: error))) } } } + func deletePackage(siteID: Int64, + packageID: String, + completion: @escaping (Result) -> Void) { + remote.deletePackage(siteID: siteID, packageID: packageID) { [weak self] result in + switch result { + case .success(let packages): + self?.upsertCreatePackagesResponseInBackground(readOnlyPackages: packages, siteID: siteID, onCompletion: { + completion(.success(packages)) + }) + case .failure(let error): + completion(.failure(error)) + } + } + } + func loadLabelRates(siteID: Int64, orderID: Int64, originAddress: ShippingLabelAddress, @@ -97,8 +118,17 @@ private extension WooShippingStore { } func loadPackages(siteID: Int64, - completion: @escaping (Result) -> Void) { - remote.loadPackages(siteID: siteID, completion: completion) + completion: @escaping (Result) -> Void) { + remote.loadPackages(siteID: siteID) { [weak self] result in + switch result { + case .success(let packages): + self?.upsertPackagesResponseInBackground(readOnlyPackages: packages, siteID: siteID) { + completion(.success(packages)) + } + case .failure(let error): + completion(.failure(WooShippingLoadPackagesError.loadingFailed(error: error))) + } + } } func loadAccountSettings(siteID: Int64, @@ -155,6 +185,11 @@ private extension WooShippingStore { completion: @escaping (Result) -> Void) { remote.printLabel(siteID: siteID, labelIDs: labelIDs, paperSize: paperSize, completion: completion) } + + func loadOriginAddresses(siteID: Int64, + completion: @escaping (Result<[WooShippingOriginAddress], Error>) -> Void) { + remote.loadOriginAddresses(siteID: siteID, completion: completion) + } } // MARK: Helpers @@ -217,6 +252,224 @@ private extension WooShippingStore { } } +// MARK: - Storage +private extension WooShippingStore { + /// Updates (OR Inserts) the specified ReadOnly WooShippingPackagesResponse Entities *in a background thread*. + /// `onCompletion` will be called on the main thread! + /// + func upsertPackagesResponseInBackground(readOnlyPackages: Networking.WooShippingPackagesResponse, + siteID: Int64, + onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ [weak self] storage in + guard let self else { return } + upsertPackagesResponse(readOnlyPackages: readOnlyPackages, in: storage) + }, completion: onCompletion, on: .main) + } + + /// Updates (OR Inserts) the specified ReadOnly WooShippingCreatePackageResponse Entities *in a background thread*. + /// `onCompletion` will be called on the main thread! + /// + func upsertCreatePackagesResponseInBackground(readOnlyPackages: Networking.WooShippingCreatePackageResponse, + siteID: Int64, + onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ [weak self] storage in + guard let self else { return } + upsertCreatePackageResponse(readOnlyPackages: readOnlyPackages, siteID: siteID, in: storage) + }, completion: onCompletion, on: .main) + } + + /// Updates (OR Inserts) the specified ReadOnly `WooShippingPackagesResponse` Entities into the Storage Layer. + /// + /// - Parameters: + /// - readOnlyPackages: Remote `WooShippingPackagesResponse` to be persisted. + /// - storage: Where we should save all the things! + /// + func upsertPackagesResponse(readOnlyPackages: Networking.WooShippingPackagesResponse, in storage: StorageType) { + let storagePackages = storage.loadPackages(siteID: readOnlyPackages.siteID) ?? + storage.insertNewObject(ofType: Storage.WooShippingPackagesResponse.self) + + storagePackages.update(with: readOnlyPackages) + handleAllPredefinedOptions(readOnlyPackages, storagePackages, storage) + handleCustomPackages(readOnlyPackages.customPackages, storagePackages, storage) + handleSavedPredefinedPackages(readOnlyPackages.savedPredefinedPackages, storagePackages, storage) + } + + /// Updates (OR Inserts) the specified ReadOnly `WooShippingCreatePackageResponse` Entities into the Storage Layer. + /// + /// - Parameters: + /// - readOnlyPackages: Remote `WooShippingCreatePackageResponse` to be persisted. + /// - siteID: Site ID to be associated with the packages. + /// - storage: Where we should save all the things! + /// + func upsertCreatePackageResponse(readOnlyPackages: Networking.WooShippingCreatePackageResponse, siteID: Int64, in storage: StorageType) { + let storagePackages = storage.loadPackages(siteID: siteID) ?? storage.insertNewObject(ofType: Storage.WooShippingPackagesResponse.self) + storagePackages.siteID = siteID + + handleCustomPackages(readOnlyPackages.customPackages, storagePackages, storage) + handleSavedPredefinedOptions(readOnlyPackages.predefinedOptions, storagePackages, storage) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingPackagesResponse's allPredefinedOptions + /// using the provided read-only WooShippingPackagesResponse's allPredefinedOptions + /// + func handleAllPredefinedOptions(_ readOnlyPackages: Networking.WooShippingPackagesResponse, + _ storagePackages: Storage.WooShippingPackagesResponse, + _ storage: StorageType) { + // Remove all previous predefined options, they will be deleted as they have the `cascade` delete rule + if let allPredefinedOptions = storagePackages.allPredefinedOptions { + storagePackages.removeFromAllPredefinedOptions(allPredefinedOptions) + } + + // Creates and adds `storageAllPredefinedOptions` from `readOnlyPackages.allPredefinedOptions` + let storageAllPredefinedOptions = readOnlyPackages.allPredefinedOptions.map { readOnlyCarrierOptions -> Storage.WooShippingCarrierPredefinedOptions in + let storageCarrierOptions = storage.insertNewObject(ofType: Storage.WooShippingCarrierPredefinedOptions.self) + storageCarrierOptions.update(with: readOnlyCarrierOptions) + handlePredefinedOptions(readOnlyCarrierOptions, storageCarrierOptions, storage) + return storageCarrierOptions + } + storagePackages.addToAllPredefinedOptions(NSOrderedSet(array: storageAllPredefinedOptions)) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingCarrierPredefinedOptions's predefinedOptions + /// using the provided read-only WooShippingCarrierPredefinedOptions's predefinedOptions + func handlePredefinedOptions(_ readOnlyCarrierOptions: Networking.WooShippingCarrierPredefinedOptions, + _ storageCarrierOptions: Storage.WooShippingCarrierPredefinedOptions, + _ storage: StorageType) { + // Remove all previous predefined options, they will be deleted as they have the `cascade` delete rule + if let predefinedOptions = storageCarrierOptions.predefinedOptions { + storageCarrierOptions.removeFromPredefinedOptions(predefinedOptions) + } + + // Creates and adds `storagePredefinedOptions` from `readOnlyCarriers.predefinedOptions` + let storagePredefinedOptions = readOnlyCarrierOptions.predefinedOptions.map { readOnlyOption -> Storage.WooShippingPredefinedOption in + let storageOption = storage.insertNewObject(ofType: Storage.WooShippingPredefinedOption.self) + storageOption.update(with: readOnlyOption) + handlePredefinedPackages(readOnlyOption, storageOption, storage) + return storageOption + } + storageCarrierOptions.addToPredefinedOptions(NSOrderedSet(array: storagePredefinedOptions)) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingPredefinedOption's predefinedPackages + /// using the provided read-only WooShippingPredefinedOption's predefinedPackages + func handlePredefinedPackages(_ readOnlyOption: Networking.WooShippingPredefinedOption, + _ storageOption: Storage.WooShippingPredefinedOption, + _ storage: StorageType) { + // Remove all previous predefined packages, they will be deleted as they have the `cascade` delete rule + if let predefinedPackages = storageOption.predefinedPackages { + storageOption.removeFromPredefinedPackages(NSSet(set: predefinedPackages)) + } + + // Creates and adds `storagePredefinedPackages` from `readOnlyOption.predefinedPackages` + let storagePredefinedPackages = readOnlyOption.predefinedPackages.map { readOnlyPackage -> Storage.WooShippingPredefinedPackage in + let storagePackage = storage.insertNewObject(ofType: Storage.WooShippingPredefinedPackage.self) + storagePackage.update(with: readOnlyPackage) + return storagePackage + } + storageOption.addToPredefinedPackages(NSSet(array: storagePredefinedPackages)) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingPackagesResponse's customPackages + /// using the provided read-only WooShippingCustomPackages + /// + func handleCustomPackages(_ readOnlyCustomPackages: [Networking.WooShippingCustomPackage], + _ storagePackages: Storage.WooShippingPackagesResponse, + _ storage: StorageType) { + // Remove all previous custom packages, they will be deleted as they have the `cascade` delete rule + if let customPackages = storagePackages.customPackages { + storagePackages.removeFromCustomPackages(NSSet(set: customPackages)) + } + + // Creates and adds `storageCustomPackages` from `readOnlyPackages.customPackages` + let storageCustomPackages = readOnlyCustomPackages.map { readOnlyPackage -> Storage.WooShippingCustomPackage in + let storagePackage = storage.insertNewObject(ofType: Storage.WooShippingCustomPackage.self) + storagePackage.update(with: readOnlyPackage) + return storagePackage + } + storagePackages.addToCustomPackages(NSSet(array: storageCustomPackages)) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingPackagesResponse's savedPredefinedPackages + /// using the provided read-only WooShippingPackagesResponse's savedPredefinedPackages + /// + func handleSavedPredefinedPackages(_ readOnlySavedPackages: [Networking.WooShippingSavedPredefinedPackage], + _ storagePackages: Storage.WooShippingPackagesResponse, + _ storage: StorageType) { + // Remove all previous saved predefined packages, they will be deleted as they have the `cascade` delete rule + if let savedPredefinedPackages = storagePackages.savedPredefinedPackages { + storagePackages.removeFromSavedPredefinedPackages(NSSet(set: savedPredefinedPackages)) + } + + // Creates and adds `storageSavedPredefinedPackages` from `readOnlyPackages.savedPredefinedPackages` + let storageSavedPredefinedPackages = readOnlySavedPackages.map { readOnlyPackage -> Storage.WooShippingSavedPredefinedPackage in + let storagePackage = storage.insertNewObject(ofType: Storage.WooShippingSavedPredefinedPackage.self) + storagePackage.update(with: readOnlyPackage) + handlePredefinedPackage(readOnlyPackage, storagePackage, storage) + return storagePackage + } + storagePackages.addToSavedPredefinedPackages(NSSet(array: storageSavedPredefinedPackages)) + } + + /// Updates, inserts, or prunes the provided Storage.WooShippingPackagesResponse's savedPredefinedPackages + /// using the provided read-only WooShippingPredefinedSavedOptions + /// + func handleSavedPredefinedOptions(_ readOnlySavedOptions: [WooShippingPredefinedSavedOption], + _ storagePackages: Storage.WooShippingPackagesResponse, + _ storage: StorageType) { + guard let storagePredefinedOptions: [StorageWooShippingCarrierPredefinedOptions] = storagePackages.allPredefinedOptions?.toArray() else { + return + } + let readOnlyPredefinedOptions = storagePredefinedOptions.map({ $0.toReadOnly() }) + let savedPackages = transformSavedPredefinedOptions(readOnlySavedOptions, allPredefinedOptions: readOnlyPredefinedOptions) + handleSavedPredefinedPackages(savedPackages, storagePackages, storage) + } + + /// Transforms the provided `WooShippingPredefinedSavedOption`s into `WooShippingSavedPredefinedPackage`s to save in storage. + /// + func transformSavedPredefinedOptions(_ options: [WooShippingPredefinedSavedOption], + allPredefinedOptions: [WooShippingCarrierPredefinedOptions]) -> [WooShippingSavedPredefinedPackage] { + // helper function for creating jointIDs for easier checking if package should be used or not + func jointID(carrierID: String, packageID: String) -> String { + return "\(carrierID)-\(packageID)" + } + + var jointIDs: [String] = [] + for option in options { + for packageID in option.predefinedPackageIDs { + jointIDs.append(jointID(carrierID: option.id, packageID: packageID)) + } + } + + var allSavedOptions: [WooShippingSavedPredefinedPackage] = [] + + // use predefined saved packages from list of all packages + // since the response gives us IDs we need to get them manually from the list + for carrier in allPredefinedOptions { + let carrierID = carrier.carrierID + for option in carrier.predefinedOptions { + for package in option.predefinedPackages { + if jointIDs.contains(jointID(carrierID: carrierID, packageID: package.id)) { + allSavedOptions.append(WooShippingSavedPredefinedPackage(groupTitle: option.title, providerID: option.providerID, package: package)) + } + } + } + } + + return allSavedOptions + } + + /// Updates or inserts the provided Storage.WooShippingSavedPredefinedPackage's package + /// using the provided read-only WooShippingSavedPredefinedPackage's package + /// + func handlePredefinedPackage(_ readOnlySavedPackage: Networking.WooShippingSavedPredefinedPackage, + _ storageSavedPackage: Storage.WooShippingSavedPredefinedPackage, + _ storage: StorageType) { + let predefinedPackage = storageSavedPackage.package ?? storage.insertNewObject(ofType: Storage.WooShippingPredefinedPackage.self) + predefinedPackage.update(with: readOnlySavedPackage.package) + storageSavedPackage.package = predefinedPackage + } +} + /// Represents errors that can be returned when purchasing a shipping label public enum WooShippingLabelPurchaseError: Error { /// API returns a `PURCHASE_ERROR` status for a label @@ -224,3 +477,12 @@ public enum WooShippingLabelPurchaseError: Error { /// No labels are returned by initial purchase request case purchaseMissingLabels } + +public enum WooShippingLoadPackagesError: Error, Equatable { + case loadingInProgress + case loadingFailed(error: Error) + + public static func ==(lhs: WooShippingLoadPackagesError, rhs: WooShippingLoadPackagesError) -> Bool { + return lhs.localizedDescription == rhs.localizedDescription + } +} diff --git a/Yosemite/Yosemite/Tools/POS/POSOrderService.swift b/Yosemite/Yosemite/Tools/POS/POSOrderService.swift index 2278219d889..9f4a0504b4f 100644 --- a/Yosemite/Yosemite/Tools/POS/POSOrderService.swift +++ b/Yosemite/Yosemite/Tools/POS/POSOrderService.swift @@ -85,17 +85,12 @@ public protocol POSOrderServiceProtocol { /// - order: Optional latest remotely synced order. Nil when syncing order for the first time. /// - Returns: Order from the remote sync. func syncOrder(cart: [POSCartItem], order: Order?) async throws -> Order - func sendReceipt(order: Order, recipientEmail: String) async throws + func updatePOSOrder(order: Order, recipientEmail: String) async throws } public final class POSOrderService: POSOrderServiceProtocol { - // MARK: - Properties - private let siteID: Int64 private let ordersRemote: POSOrdersRemoteProtocol - private let receiptsRemote: POSReceiptsRemoteProtocol - - // MARK: - Initialization public convenience init?(siteID: Int64, credentials: Credentials?) { guard let credentials else { @@ -104,14 +99,13 @@ public final class POSOrderService: POSOrderServiceProtocol { } let network = AlamofireNetwork(credentials: credentials) self.init(siteID: siteID, - ordersRemote: OrdersRemote(network: network), - receiptsRemote: ReceiptRemote(network: network)) + ordersRemote: OrdersRemote(network: network)) } - public init(siteID: Int64, ordersRemote: POSOrdersRemoteProtocol, receiptsRemote: POSReceiptsRemoteProtocol) { + public init(siteID: Int64, + ordersRemote: POSOrdersRemoteProtocol) { self.siteID = siteID self.ordersRemote = ordersRemote - self.receiptsRemote = receiptsRemote } // MARK: - Protocol conformance @@ -128,15 +122,18 @@ public final class POSOrderService: POSOrderServiceProtocol { return syncedOrder } - public func sendReceipt(order: Order, recipientEmail: String) async throws { + public func updatePOSOrder(order: Order, recipientEmail: String) async throws { guard order.billingAddress?.email == nil || order.billingAddress?.email == "" else { throw POSOrderServiceError.emailAlreadySet } let updatedBillingAddress = order.billingAddress?.copy(email: recipientEmail) let updatedOrder = order.copy(billingAddress: updatedBillingAddress) - let _ = try await ordersRemote.updatePOSOrder(siteID: siteID, order: updatedOrder, fields: [.billingAddress]) - try await receiptsRemote.sendReceipt(siteID: siteID, orderID: order.orderID) + do { + let _ = try await ordersRemote.updatePOSOrder(siteID: siteID, order: updatedOrder, fields: [.billingAddress]) + } catch { + throw POSOrderServiceError.updateOrderFailed + } } } @@ -193,5 +190,6 @@ private extension POSOrderService { private extension POSOrderService { enum POSOrderServiceError: Error { case emailAlreadySet + case updateOrderFailed } } diff --git a/Yosemite/Yosemite/Tools/POS/POSReceiptService.swift b/Yosemite/Yosemite/Tools/POS/POSReceiptService.swift new file mode 100644 index 00000000000..7505fe6b515 --- /dev/null +++ b/Yosemite/Yosemite/Tools/POS/POSReceiptService.swift @@ -0,0 +1,41 @@ +import SwiftUI +import Networking + +public protocol POSReceiptServiceProtocol { + func sendReceipt(order: Order, recipientEmail: String) async throws +} + +public final class POSReceiptService: POSReceiptServiceProtocol { + private let siteID: Int64 + private let receiptsRemote: POSReceiptsRemoteProtocol + + public convenience init?(siteID: Int64, credentials: Credentials?) { + guard let credentials else { + DDLogError("⛔️ Could not create POSReceiptService due to not finding credentials") + return nil + } + let network = AlamofireNetwork(credentials: credentials) + self.init(siteID: siteID, + receiptsRemote: ReceiptRemote(network: network)) + } + + public init(siteID: Int64, + receiptsRemote: POSReceiptsRemoteProtocol) { + self.siteID = siteID + self.receiptsRemote = receiptsRemote + } + + public func sendReceipt(order: Yosemite.Order, recipientEmail: String) async throws { + do { + try await receiptsRemote.sendReceipt(siteID: siteID, orderID: order.orderID) + } catch { + throw POSReceiptServiceError.sendReceiptFailed + } + } +} + +public extension POSReceiptService { + enum POSReceiptServiceError: Error { + case sendReceiptFailed + } +} diff --git a/WooCommerce/Classes/ViewModels/ProductVariationFormatter.swift b/Yosemite/Yosemite/Tools/ProductVariations/ProductVariationFormatter.swift similarity index 81% rename from WooCommerce/Classes/ViewModels/ProductVariationFormatter.swift rename to Yosemite/Yosemite/Tools/ProductVariations/ProductVariationFormatter.swift index 06ba7d464f2..9039bc667ec 100644 --- a/WooCommerce/Classes/ViewModels/ProductVariationFormatter.swift +++ b/Yosemite/Yosemite/Tools/ProductVariations/ProductVariationFormatter.swift @@ -1,16 +1,16 @@ import Foundation -import Yosemite /// Helper to format product variation details, such as variation name or attributes. /// -struct ProductVariationFormatter { +public struct ProductVariationFormatter { + public init() {} /// Generates a name for the product variation, given a list of the parent product attributes, e.g. "Blue - Any Size" /// - Parameters: /// - variation: The product variation whose name is being generated /// - allAttributes: A list of attributes from the parent `Product` /// - func generateName(for variation: ProductVariation, from allAttributes: [ProductAttribute]) -> String { + public func generateName(for variation: ProductVariation, from allAttributes: [ProductAttribute]) -> String { let variationAttributes = generateAttributes(for: variation, from: allAttributes) return variationAttributes.map { $0.nameOrValue }.joined(separator: " - ") } @@ -20,7 +20,7 @@ struct ProductVariationFormatter { /// - variation: The product variation whose attributes are being generated /// - allAttributes: A list of attributes from the parent `Product` /// - func generateAttributes(for variation: ProductVariation, from allAttributes: [ProductAttribute]) -> [VariationAttributeViewModel] { + public func generateAttributes(for variation: ProductVariation, from allAttributes: [ProductAttribute]) -> [VariationAttributeViewModel] { return allAttributes .sorted(by: { (lhs, rhs) -> Bool in lhs.position < rhs.position diff --git a/Yosemite/Yosemite/Tools/ProductVariations/VariationAttributeViewModel.swift b/Yosemite/Yosemite/Tools/ProductVariations/VariationAttributeViewModel.swift new file mode 100644 index 00000000000..f514c456832 --- /dev/null +++ b/Yosemite/Yosemite/Tools/ProductVariations/VariationAttributeViewModel.swift @@ -0,0 +1,42 @@ +import Foundation + +/// View Model for a Variation Attribute. +public struct VariationAttributeViewModel: Equatable { + + /// Attribute name + /// + public let name: String + + /// Attribute value + /// + public let value: String? + + /// Returns the attribute value, or "Any \(name)" if the attribute value is nil or empty + /// + public var nameOrValue: String { + guard let value = value, !value.isEmpty else { + return String(format: Localization.anyAttributeFormat, name) + } + return value + } + + public init(name: String, value: String? = nil) { + self.name = name + self.value = value + } + + public init(orderItemAttribute: OrderItemAttribute) { + self.init(name: orderItemAttribute.name, value: orderItemAttribute.value) + } + + init(productVariationAttribute: ProductVariationAttribute) { + self.init(name: productVariationAttribute.name, value: productVariationAttribute.option) + } +} + +extension VariationAttributeViewModel { + enum Localization { + static let anyAttributeFormat = + NSLocalizedString("Any %1$@", comment: "Format of a product variation attribute description where the attribute is set to any value.") + } +} diff --git a/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift b/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift index f95b66aee44..e3c1801164d 100644 --- a/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift +++ b/Yosemite/YosemiteTests/Mocks/CardPresentPayments/MockCardReaderService.swift @@ -20,6 +20,10 @@ final class MockCardReaderService: CardReaderService { CurrentValueSubject(.none).eraseToAnyPublisher() } + var builtInCardReaderAcceptToSEvents: AnyPublisher { + PassthroughSubject().eraseToAnyPublisher() + } + /// Boolean flag Indicates that clients have called the start method var didHitStart = false diff --git a/Yosemite/YosemiteTests/Mocks/MockPOSOrderableItem.swift b/Yosemite/YosemiteTests/Mocks/MockPOSOrderableItem.swift index e048be25787..b14560fc077 100644 --- a/Yosemite/YosemiteTests/Mocks/MockPOSOrderableItem.swift +++ b/Yosemite/YosemiteTests/Mocks/MockPOSOrderableItem.swift @@ -1,7 +1,7 @@ import Foundation @testable import Yosemite -final class MockPOSItem: POSOrderableItem, Equatable { +final class MockPOSOrderableItem: POSOrderableItem, Equatable { var name: String var id: UUID var formattedPrice: String @@ -44,7 +44,7 @@ final class MockPOSItem: POSOrderableItem, Equatable { return true } - static func == (lhs: MockPOSItem, rhs: MockPOSItem) -> Bool { + static func == (lhs: MockPOSOrderableItem, rhs: MockPOSOrderableItem) -> Bool { return lhs.name == rhs.name && lhs.id == rhs.id && lhs.formattedPrice == rhs.formattedPrice && diff --git a/Yosemite/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Yosemite/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index e9cb3f9557b..95651f5954f 100644 --- a/Yosemite/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -1,9 +1,5 @@ import Networking -final class MockReceiptsOrderRemote: POSReceiptsRemoteProtocol { - func sendReceipt(siteID: Int64, orderID: Int64) async throws { } -} - final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { var updatePOSOrderCalled: Bool = false var spyUpdatePOSOrder: Order? diff --git a/Yosemite/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift b/Yosemite/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift new file mode 100644 index 00000000000..a757a1c8a80 --- /dev/null +++ b/Yosemite/YosemiteTests/Mocks/MockPOSReceiptsRemote.swift @@ -0,0 +1,18 @@ +import Networking + +final class MockPOSReceiptsRemote: POSReceiptsRemoteProtocol { + var sendReceiptCalled = false + var spySiteID: Int64? + var spyOrderID: Int64? + var shouldThrowError: Error? + + func sendReceipt(siteID: Int64, orderID: Int64) async throws { + sendReceiptCalled = true + spySiteID = siteID + spyOrderID = orderID + + if let shouldThrowError { + throw shouldThrowError + } + } +} diff --git a/Yosemite/YosemiteTests/Mocks/MockStorageManager+Sample.swift b/Yosemite/YosemiteTests/Mocks/MockStorageManager+Sample.swift index 548767c3abc..862be678dae 100644 --- a/Yosemite/YosemiteTests/Mocks/MockStorageManager+Sample.swift +++ b/Yosemite/YosemiteTests/Mocks/MockStorageManager+Sample.swift @@ -248,4 +248,41 @@ extension MockStorageManager { return newNote } + + /// Inserts a new sample Woo Shipping packages response into the specified content. + /// + @discardableResult + func insertSamplePackages(readOnlyPackages: WooShippingPackagesResponse) -> StorageWooShippingPackagesResponse { + let newPackages = viewStorage.insertNewObject(ofType: StorageWooShippingPackagesResponse.self) + newPackages.update(with: readOnlyPackages) + readOnlyPackages.allPredefinedOptions.forEach { carrierOption in + let newCarrierOption = viewStorage.insertNewObject(ofType: StorageWooShippingCarrierPredefinedOptions.self) + newCarrierOption.update(with: carrierOption) + newPackages.addToAllPredefinedOptions(newCarrierOption) + carrierOption.predefinedOptions.forEach { predefinedOption in + let newPredefinedOption = viewStorage.insertNewObject(ofType: StorageWooShippingPredefinedOption.self) + newPredefinedOption.update(with: predefinedOption) + newCarrierOption.addToPredefinedOptions(newPredefinedOption) + predefinedOption.predefinedPackages.forEach { package in + let newPackage = viewStorage.insertNewObject(ofType: StorageWooShippingPredefinedPackage.self) + newPackage.update(with: package) + newPredefinedOption.addToPredefinedPackages(newPackage) + } + } + } + readOnlyPackages.customPackages.forEach { customPackage in + let newCustomPackage = viewStorage.insertNewObject(ofType: StorageWooShippingCustomPackage.self) + newCustomPackage.update(with: customPackage) + newPackages.addToCustomPackages(newCustomPackage) + } + readOnlyPackages.savedPredefinedPackages.forEach { savedPackage in + let newSavedPackage = viewStorage.insertNewObject(ofType: StorageWooShippingSavedPredefinedPackage.self) + newSavedPackage.update(with: savedPackage) + newPackages.addToSavedPredefinedPackages(newSavedPackage) + let newPackage = viewStorage.insertNewObject(ofType: StorageWooShippingPredefinedPackage.self) + newPackage.update(with: savedPackage.package) + newSavedPackage.package = newPackage + } + return newPackages + } } diff --git a/Yosemite/YosemiteTests/Mocks/MockStorageManager.swift b/Yosemite/YosemiteTests/Mocks/MockStorageManager.swift index ddef9cd1bc4..3e702e6b10f 100644 --- a/Yosemite/YosemiteTests/Mocks/MockStorageManager.swift +++ b/Yosemite/YosemiteTests/Mocks/MockStorageManager.swift @@ -17,7 +17,7 @@ public class MockStorageManager: StorageManagerType { return persistentContainer.viewContext } - /// Returns a shared derived storage instance dedicated for write operations. + /// Returns a shared derived storage instance dedicated for write operations in the background. /// public lazy var writerDerivedStorage: StorageType = { let childManagedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) @@ -56,7 +56,7 @@ public class MockStorageManager: StorageManagerType { /// This method effectively destroys all of the stored data, and generates a blank Persistent Store from scratch. /// - public func reset() { + public func reset(onCompletion: (() -> Void)?) { let storeCoordinator = persistentContainer.persistentStoreCoordinator let storeDescriptor = self.storeDescription let viewContext = persistentContainer.viewContext @@ -73,6 +73,7 @@ public class MockStorageManager: StorageManagerType { storeCoordinator.addPersistentStore(with: storeDescriptor) { (_, error) in guard let error = error else { + onCompletion?() return } @@ -160,3 +161,10 @@ extension MockStorageManager { return url } } + +extension StorageType { + @available(*, deprecated, message: "Use `MockStorageManager`'s `performAndSave` to handle write operations instead of writing directly.") + func saveIfNeeded() { + (self as! NSManagedObjectContext).saveIfNeeded() + } +} diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockAccountRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockAccountRemote.swift index eda3cd1a8b8..1cc01659bd3 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockAccountRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockAccountRemote.swift @@ -22,6 +22,9 @@ final class MockAccountRemote { /// The results to return based on the given site ID in `loadUsernameSuggestions`. private var loadUsernameSuggestionsResult: Result<[String], Error>? + /// Returns the value when `updateNotificationSettings` is called. + private var updateNotificationSettingsResult: Result = .success(()) + /// The results to return based on the given site ID in `createAccount`. private var createAccountResult: Result? @@ -52,6 +55,11 @@ final class MockAccountRemote { func whenCreatingAccount(thenReturn result: Result) { createAccountResult = result } + + /// Returns the value when `updateNotificationSettings` is called. + func whenUpdatingNotificationSettings(thenReturn result: Result) { + updateNotificationSettingsResult = result + } } extension MockAccountRemote { @@ -115,6 +123,15 @@ extension MockAccountRemote: AccountRemoteProtocol { return try result.get() } + func updateNotificationSettings(with settings: NotificationSettings) async throws { + switch updateNotificationSettingsResult { + case .success: + break + case .failure(let error): + throw error + } + } + func createAccount(email: String, username: String, password: String, diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift index 722d9f9c3bd..794d4604da6 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockMediaRemote.swift @@ -13,19 +13,13 @@ final class MockMediaRemote { private var loadMediaResultsBySiteID = [Int64: Result]() /// The results to return based on the given site ID in `loadMediaLibrary` - private var loadMediaLibraryResultsBySiteID = [Int64: Result<[Media], Error>]() - - /// The results to return based on the given site ID in `loadMediaLibraryFromWordPressSite` - private var loadMediaLibraryFromWordPressSiteResultsBySiteID = [Int64: Result<[WordPressMedia], Error>]() + private var loadMediaLibraryResultsBySiteID = [Int64: Result<[WordPressMedia], Error>]() /// The results to return based on the given site ID in `uploadMedia` private var uploadMediaResultsBySiteID = [Int64: Result]() /// The results to return based on the given site ID in `updateProductID` - private var updateProductIDResultsBySiteID = [Int64: Result]() - - /// The results to return based on the given site ID in `updateProductIDToWordPressSite` - private var updateProductIDToWordPressSiteResultsBySiteID = [Int64: Result]() + private var updateProductIDResultsBySiteID = [Int64: Result]() /// Returns the value as a publisher when `loadMedia` is called. func whenLoadingMedia(siteID: Int64, thenReturn result: Result) { @@ -33,36 +27,25 @@ final class MockMediaRemote { } /// Returns the value as a publisher when `loadMediaLibrary` is called. - func whenLoadingMediaLibrary(siteID: Int64, thenReturn result: Result<[Media], Error>) { + func whenLoadingMediaLibrary(siteID: Int64, thenReturn result: Result<[WordPressMedia], Error>) { loadMediaLibraryResultsBySiteID[siteID] = result } - /// Returns the value as a publisher when `loadMediaLibraryFromWordPressSite` is called. - func whenLoadingMediaLibraryFromWordPressSite(siteID: Int64, thenReturn result: Result<[WordPressMedia], Error>) { - loadMediaLibraryFromWordPressSiteResultsBySiteID[siteID] = result - } - /// Returns the value as a publisher when `uploadMedia` is called. func whenUploadingMedia(siteID: Int64, thenReturn result: Result) { uploadMediaResultsBySiteID[siteID] = result } /// Returns the value as a publisher when `updateProductID` is called. - func whenUpdatingProductID(siteID: Int64, thenReturn result: Result) { + func whenUpdatingProductID(siteID: Int64, thenReturn result: Result) { updateProductIDResultsBySiteID[siteID] = result } - - /// Returns the value as a publisher when `updateProductIDToWordPressSite` is called. - func whenUpdatingProductIDToWordPressSite(siteID: Int64, thenReturn result: Result) { - updateProductIDToWordPressSiteResultsBySiteID[siteID] = result - } } extension MockMediaRemote { enum Invocation: Equatable { case loadMedia(siteID: Int64, mediaID: Int64) case loadMediaLibrary(siteID: Int64) - case loadMediaLibraryFromWordPressSite(siteID: Int64) case uploadMedia(siteID: Int64) case updateProductID(siteID: Int64) case updateProductIDToWordPressSite(siteID: Int64) @@ -81,13 +64,12 @@ extension MockMediaRemote: MediaRemoteProtocol { completion(result) } - func loadMediaLibrary(for siteID: Int64, + func loadMediaLibrary(siteID: Int64, productID: Int64?, imagesOnly: Bool, pageNumber: Int, pageSize: Int, - context: String?, - completion: @escaping (Result<[Media], Error>) -> Void) { + completion: @escaping (Result<[WordPressMedia], Error>) -> Void) { invocations.append(.loadMediaLibrary(siteID: siteID)) guard let result = loadMediaLibraryResultsBySiteID[siteID] else { XCTFail("\(String(describing: self)) Could not find result for site ID: \(siteID)") @@ -96,20 +78,6 @@ extension MockMediaRemote: MediaRemoteProtocol { completion(result) } - func loadMediaLibraryFromWordPressSite(siteID: Int64, - productID: Int64?, - imagesOnly: Bool, - pageNumber: Int, - pageSize: Int, - completion: @escaping (Result<[WordPressMedia], Error>) -> Void) { - invocations.append(.loadMediaLibraryFromWordPressSite(siteID: siteID)) - guard let result = loadMediaLibraryFromWordPressSiteResultsBySiteID[siteID] else { - XCTFail("\(String(describing: self)) Could not find result for site ID: \(siteID)") - return - } - completion(result) - } - func uploadMedia(siteID: Int64, productID: Int64, mediaItem: UploadableMedia, @@ -125,7 +93,7 @@ extension MockMediaRemote: MediaRemoteProtocol { func updateProductID(siteID: Int64, productID: Int64, mediaID: Int64, - completion: @escaping (Result) -> Void) { + completion: @escaping (Result) -> Void) { invocations.append(.updateProductID(siteID: siteID)) guard let result = updateProductIDResultsBySiteID[siteID] else { XCTFail("\(String(describing: self)) Could not find result for site ID: \(siteID)") @@ -133,16 +101,4 @@ extension MockMediaRemote: MediaRemoteProtocol { } completion(result) } - - func updateProductIDToWordPressSite(siteID: Int64, - productID: Int64, - mediaID: Int64, - completion: @escaping (Result) -> Void) { - invocations.append(.updateProductIDToWordPressSite(siteID: siteID)) - guard let result = updateProductIDToWordPressSiteResultsBySiteID[siteID] else { - XCTFail("\(String(describing: self)) Could not find result for site ID: \(siteID)") - return - } - completion(result) - } } diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductVariationsRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductVariationsRemote.swift index 0d2294081f2..db31d99af3f 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductVariationsRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductVariationsRemote.swift @@ -91,6 +91,10 @@ extension MockProductVariationsRemote: ProductVariationsRemoteProtocol { // no-op } + func loadVariationsForPointOfSale(for siteID: Int64, parentProductID: Int64, pageNumber: Int) async throws -> [ProductVariation] { + [] + } + func loadProductVariation(for siteID: Int64, productID: Int64, variationID: Int64, completion: @escaping (Result) -> Void) { DispatchQueue.main.async { [weak self] in guard let self = self else { diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift index 3685bddb8be..d43b8115e41 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift @@ -56,6 +56,8 @@ final class MockProductsRemote { private var searchProductsBySKUResultsBySKU = [String: Result<[Product], Error>]() + private var searchProductsByGlobalUniqueIdentifierResults = [String: Result<[Product], Error>]() + private var fetchedStockResult: Result<[ProductStock], Error>? private var fetchedProductReports: Result<[ProductReport], Error>? private var fetchedVariationReports: Result<[ProductReport], Error>? @@ -120,6 +122,13 @@ final class MockProductsRemote { searchProductsBySKUResultsBySKU[sku] = result } + /// Set the value passed to the `completion` block if `searchProductsByGlobalUniqueIdentifier()` is called. + /// + func whenSearchingProductsByGlobalUniqueIdentifier(identifier: String, thenReturn result: Result<[Product], Error>) { + searchProductsByGlobalUniqueIdentifierResults[identifier] = result + } + + func whenFetchingStock(thenReturn result: Result<[ProductStock], Error>) { fetchedStockResult = result } @@ -265,7 +274,11 @@ extension MockProductsRemote: ProductsRemoteProtocol { pageNumber: Int, pageSize: Int, completion: @escaping (Result<[Product], Error>) -> Void) { - // no-op + if let result = searchProductsByGlobalUniqueIdentifierResults[keyword] { + completion(result) + } else { + XCTFail("\(String(describing: self)) Could not find result for Global Unique Identifier \(keyword)") + } } func searchSku(for siteID: Int64, sku: String, completion: @escaping (Result) -> Void) { diff --git a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockWooShippingRemote.swift b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockWooShippingRemote.swift index c169ddc01b5..2efe4aeb85c 100644 --- a/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockWooShippingRemote.swift +++ b/Yosemite/YosemiteTests/Mocks/Networking/Remote/MockWooShippingRemote.swift @@ -15,6 +15,9 @@ final class MockWooShippingRemote { /// The results to return based on the given arguments in `createPackage` private var createPackageResults = [ResultKey: Result]() + /// The results to return based on the given arguments in `deletePackage` + private var deletePackageResults = [ResultKey: Result]() + /// The results to return based on the given arguments in `loadLabelRates` private var loadLabelRatesResults = [ResultKey: Result<[ShippingLabelCarriersAndRates], Error>]() @@ -33,6 +36,9 @@ final class MockWooShippingRemote { /// The results to return based on the given arguments in `printLabel` private var printLabel = [ResultKey: Result]() + /// The results to return based on the given arguments in `loadOriginAddresses` + private var loadOriginAddresses = [ResultKey: Result<[WooShippingOriginAddress], Error>]() + /// Set the value passed to the `completion` block if `createPackage` is called. func whenCreatePackage(siteID: Int64, thenReturn result: Result) { @@ -40,6 +46,13 @@ final class MockWooShippingRemote { createPackageResults[key] = result } + /// Set the value passed to the `completion` block if `deletePackage` is called. + func whenDeletePackage(siteID: Int64, + thenReturn result: Result) { + let key = ResultKey(siteID: siteID) + deletePackageResults[key] = result + } + /// Set the value passed to the `completion` block if `loadLabelRates` is called. func whenLoadLabelRates(siteID: Int64, thenReturn result: Result<[ShippingLabelCarriersAndRates], Error>) { @@ -81,6 +94,13 @@ final class MockWooShippingRemote { let key = ResultKey(siteID: siteID) printLabel[key] = result } + + /// Set the value passed to the `completion` block if `loadOriginAddresses` is called. + func whenOriginAddresses(siteID: Int64, + thenReturn result: Result<[WooShippingOriginAddress], Error>) { + let key = ResultKey(siteID: siteID) + loadOriginAddresses[key] = result + } } // MARK: - WooShippingRemoteProtocol @@ -101,6 +121,19 @@ extension MockWooShippingRemote: WooShippingRemoteProtocol { } } + func deletePackage(siteID: Int64, packageID: String, completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let key = ResultKey(siteID: siteID) + if let result = self.deletePackageResults[key] { + completion(result) + } else { + XCTFail("\(String(describing: self)) Could not find Result for \(key)") + } + } + } + func loadLabelRates(siteID: Int64, orderID: Int64, originAddress: ShippingLabelAddress, @@ -198,4 +231,18 @@ extension MockWooShippingRemote: WooShippingRemoteProtocol { } } } + + func loadOriginAddresses(siteID: Int64, + completion: @escaping (Result<[Networking.WooShippingOriginAddress], any Error>) -> Void) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let key = ResultKey(siteID: siteID) + if let result = self.loadOriginAddresses[key] { + completion(result) + } else { + XCTFail("\(String(describing: self)) Could not find Result for \(key)") + } + } + } } diff --git a/Yosemite/YosemiteTests/Model/Extensions/ReadOnlyConvertibleTests.swift b/Yosemite/YosemiteTests/Model/Extensions/ReadOnlyConvertibleTests.swift new file mode 100644 index 00000000000..017707f9faa --- /dev/null +++ b/Yosemite/YosemiteTests/Model/Extensions/ReadOnlyConvertibleTests.swift @@ -0,0 +1,254 @@ +import XCTest +import CoreData +@testable import Yosemite +@testable import Storage + +final class ReadOnlyConvertibleTests: XCTestCase { + + private let defaultDate = Date(timeIntervalSince1970: 0) + + func test_app_does_not_crash_when_converting_deleted_product() throws { + // Given + let storageManager = MockStorageManager() + let createdDate = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = Product.fake().copy(siteID: 13, productID: 3, date: createdDate, dateCreated: createdDate) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: Product.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: Product.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: Product.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: Product.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.siteID, 0) + XCTAssertEqual(readOnlyItem.productID, 0) + XCTAssertEqual(readOnlyItem.date, defaultDate) + XCTAssertEqual(readOnlyItem.dateCreated, defaultDate) + } + + func test_app_does_not_crash_when_converting_deleted_product_image() throws { + // Given + let storageManager = MockStorageManager() + let createdDate = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = ProductImage.fake().copy(imageID: 13, dateCreated: createdDate) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: ProductImage.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: ProductImage.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: ProductImage.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: ProductImage.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.imageID, 0) + XCTAssertEqual(readOnlyItem.dateCreated, defaultDate) + } + + func test_app_does_not_crash_when_converting_deleted_product_variation() throws { + // Given + let storageManager = MockStorageManager() + let createdDate = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = ProductVariation.fake().copy(siteID: 13, productID: 3, productVariationID: 1, dateCreated: createdDate) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: StorageProductVariation.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: StorageProductVariation.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: StorageProductVariation.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: StorageProductVariation.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.siteID, 0) + XCTAssertEqual(readOnlyItem.productID, 0) + XCTAssertEqual(readOnlyItem.productVariationID, 0) + XCTAssertEqual(readOnlyItem.dateCreated, defaultDate) + } + + func test_app_does_not_crash_when_converting_deleted_shipping_label() throws { + // Given + let storageManager = MockStorageManager() + let createdDate = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = ShippingLabel.fake().copy(siteID: 123, orderID: 44, shippingLabelID: 23, dateCreated: createdDate, status: .purchaseInProgress) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: ShippingLabel.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: ShippingLabel.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: ShippingLabel.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: ShippingLabel.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.siteID, 0) + XCTAssertEqual(readOnlyItem.orderID, 0) + XCTAssertEqual(readOnlyItem.shippingLabelID, 0) + XCTAssertEqual(readOnlyItem.dateCreated, defaultDate) + XCTAssertEqual(readOnlyItem.status, .unknown) + } + + func test_app_does_not_crash_when_converting_deleted_shipping_label_refund() throws { + // Given + let storageManager = MockStorageManager() + let dateRequested = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = ShippingLabelRefund(dateRequested: dateRequested, status: .pending) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: ShippingLabelRefund.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: ShippingLabelRefund.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: ShippingLabelRefund.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: ShippingLabelRefund.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.dateRequested, defaultDate) + XCTAssertEqual(readOnlyItem.status, .unknown) + } + + func test_app_does_not_crash_when_converting_deleted_wcpaycharge() throws { + // Given + let storageManager = MockStorageManager() + let createdDate = Date(timeIntervalSinceNow: -86_400) // 24h before + let remoteItem = WCPayCharge(siteID: 134, + id: "21", + amount: 23390, + amountCaptured: 23390, + amountRefunded: 0, + authorizationCode: nil, + captured: true, + created: createdDate, + currency: "USD", + paid: true, + paymentIntentID: nil, + paymentMethodID: "test", + paymentMethodDetails: .unknown, + refunded: false, + status: .succeeded) + + let fetchedItem = waitFor { promise in + storageManager.performAndSave({ storage in + let storedItem = storage.insertNewObject(ofType: WCPayCharge.self) + storedItem.update(with: remoteItem) + }, completion: { + // fetch the saved item from the view context + let fetchedItem = storageManager.viewStorage.firstObject(ofType: WCPayCharge.self) + promise(fetchedItem) + }, on: .main) + } + + // confidence check + XCTAssertNotNil(fetchedItem) + + // When: delete all stored objects + waitForExpectation { expectation in + storageManager.performAndSave({ storage in + storage.deleteAllObjects(ofType: WCPayCharge.self) + }, completion: { + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: WCPayCharge.self), 0) + expectation.fulfill() + }, on: .main) + } + + // Then this should not crash + let readOnlyItem = try XCTUnwrap(fetchedItem?.toReadOnly()) + // All properties are substituted to default values + XCTAssertEqual(readOnlyItem.siteID, 0) + XCTAssertEqual(readOnlyItem.id, "") + XCTAssertEqual(readOnlyItem.amount, 0) + XCTAssertEqual(readOnlyItem.created, defaultDate) + } +} diff --git a/Yosemite/YosemiteTests/PointOfSale/POSProductProviderTests.swift b/Yosemite/YosemiteTests/PointOfSale/POSProductProviderTests.swift deleted file mode 100644 index e495025d3de..00000000000 --- a/Yosemite/YosemiteTests/PointOfSale/POSProductProviderTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -import XCTest -import WooFoundation -@testable import Networking -@testable import Yosemite - -final class PointOfSaleProductServiceTests: XCTestCase { - private var currencySettings: CurrencySettings! - private var itemProvider: PointOfSaleItemServiceProtocol! - private var network: MockNetwork! - private let siteID: Int64 = 123 - - override func setUp() { - super.setUp() - network = MockNetwork() - currencySettings = CurrencySettings() - itemProvider = PointOfSaleProductService(siteID: siteID, - currencySettings: currencySettings, - network: network) - } - - override func tearDown() { - currencySettings = nil - itemProvider = nil - super.tearDown() - } - - func test_PointOfSaleItemServiceProtocol_when_fails_request_with_requestFailed_then_throws_error() async throws { - // Given - let expectedError = PointOfSaleProductServiceError.requestFailed - network.simulateError(requestUrlSuffix: "products", error: expectedError) - - // When - do { - _ = try await itemProvider.providePointOfSaleItems() - XCTFail("Expected an error, but got success.") - } catch { - // Then - XCTAssertEqual(error as? PointOfSaleProductServiceError, expectedError) - } - } - - func test_PointOfSaleItemServiceProtocol_when_fails_request_with_pageOutOfRange_then_throws_error() async throws { - let expectedError = PointOfSaleProductServiceError.pageOutOfRange - network.simulateError(requestUrlSuffix: "products", error: expectedError) - - // When - do { - _ = try await itemProvider.providePointOfSaleItems() - XCTFail("Expected an error, but got success.") - } catch { - // Then - XCTAssertEqual(error as? PointOfSaleProductServiceError, expectedError) - } - } - - func test_PointOfSaleItemServiceProtocol_provides_no_items_when_store_has_no_products() async throws { - // Given/When - network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") - let expectedItems = try await itemProvider.providePointOfSaleItems() - - // Then - XCTAssertTrue(expectedItems.isEmpty) - } - - func test_PointOfSaleItemServiceProtocol_provides_items_when_store_has_eligible_products() async throws { - // Given - let expectedProductName = "Dymo LabelWriter 4XL" - let expectedProductID: Int64 = 208 - let expectedProductPrice = "216" - let expectedFormattedPrice = "$216.00" - let expectedNumberOfEligibleProducts = 6 - - // When - network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-type-simple") - let expectedItems = try await itemProvider.providePointOfSaleItems() - - // Then - guard let item = expectedItems.first else { - return XCTFail("No eligible products") - } - XCTAssertEqual(expectedItems.count, expectedNumberOfEligibleProducts) - XCTAssertEqual(item.name, expectedProductName) - XCTAssertEqual(item.formattedPrice, expectedFormattedPrice) - - guard let product = item as? POSProduct else { - return XCTFail("Expected a POSProduct") - } - XCTAssertEqual(product.price, expectedProductPrice) - XCTAssertEqual(product.productID, expectedProductID) - } - - func test_PointOfSaleItemServiceProtocol_when_eligibility_criteria_applies_then_returns_correct_number_of_items() async throws { - // Given - let expectedNumberOfItems = 2 - let expectedItemNames = ["Dymo LabelWriter 4XL", "Private Hoodie"] - - // When - network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-for-eligibility-criteria") - let expectedItems = try await itemProvider.providePointOfSaleItems() - - // Then - XCTAssertEqual(expectedItems.count, expectedNumberOfItems) - - guard let firstEligibleItem = expectedItems.first, - let secondEligibleItem = expectedItems.last else { - return XCTFail("Expected \(expectedNumberOfItems) eligible items. Got \(expectedItems.count) instead.") - } - XCTAssertEqual(firstEligibleItem.name, expectedItemNames.first) - XCTAssertEqual(secondEligibleItem.name, expectedItemNames.last) - } -} diff --git a/Yosemite/YosemiteTests/PointOfSale/PointOfSaleItemServiceTests.swift b/Yosemite/YosemiteTests/PointOfSale/PointOfSaleItemServiceTests.swift new file mode 100644 index 00000000000..acd63e8ca82 --- /dev/null +++ b/Yosemite/YosemiteTests/PointOfSale/PointOfSaleItemServiceTests.swift @@ -0,0 +1,247 @@ +import XCTest +import WooFoundation +@testable import Networking +@testable import Yosemite + +final class PointOfSaleItemServiceTests: XCTestCase { + private var currencySettings: CurrencySettings! + private var itemProvider: PointOfSaleItemServiceProtocol! + private var network: MockNetwork! + private let siteID: Int64 = 123 + + override func setUp() { + super.setUp() + network = MockNetwork() + currencySettings = CurrencySettings() + itemProvider = PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + network: network, + isVariableProductsFeatureEnabled: false) + } + + override func tearDown() { + currencySettings = nil + itemProvider = nil + super.tearDown() + } + + func test_PointOfSaleItemServiceProtocol_when_fails_request_with_requestFailed_then_throws_error() async throws { + // Given + let expectedError = PointOfSaleItemServiceError.requestFailed + network.simulateError(requestUrlSuffix: "products", error: expectedError) + + // When + do { + _ = try await itemProvider.providePointOfSaleItems() + XCTFail("Expected an error, but got success.") + } catch { + // Then + XCTAssertEqual(error as? PointOfSaleItemServiceError, expectedError) + } + } + + func test_PointOfSaleItemServiceProtocol_when_empty_data_for_non_first_page_then_returns_empty_items_and_no_next_page() async throws { + // Given + network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") + + // When + let pagedItems = try await itemProvider.providePointOfSaleItems(pageNumber: 2) + + // Then + XCTAssertTrue(pagedItems.items.isEmpty) + XCTAssertFalse(pagedItems.hasMorePages) + } + + func test_PointOfSaleItemServiceProtocol_provides_no_items_when_store_has_no_products() async throws { + // Given/When + network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array") + let pagedItems = try await itemProvider.providePointOfSaleItems() + + // Then + XCTAssertTrue(pagedItems.items.isEmpty) + } + + func test_PointOfSaleItemServiceProtocol_provides_items_when_store_has_eligible_products() async throws { + // Given + let expectedProductName = "Dymo LabelWriter 4XL" + let expectedProductID: Int64 = 208 + let expectedProductPrice = "216" + let expectedFormattedPrice = "$216.00" + let expectedNumberOfEligibleProducts = 6 + + // When + network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-type-simple") + let pagedItems = try await itemProvider.providePointOfSaleItems() + + // Then + let expectedItems = pagedItems.items + guard let item = expectedItems.first, + case .simpleProduct(let simpleProduct) = item else { + return XCTFail("No eligible products") + } + XCTAssertEqual(expectedItems.count, expectedNumberOfEligibleProducts) + XCTAssertEqual(simpleProduct.name, expectedProductName) + XCTAssertEqual(simpleProduct.formattedPrice, expectedFormattedPrice) + XCTAssertEqual(simpleProduct.price, expectedProductPrice) + XCTAssertEqual(simpleProduct.productID, expectedProductID) + } + + func test_PointOfSaleItemServiceProtocol_when_eligibility_criteria_applies_then_returns_correct_number_of_items() async throws { + // Given + let expectedNumberOfItems = 2 + let expectedItemNames = ["Dymo LabelWriter 4XL", "Private Hoodie"] + + // When + network.simulateResponse(requestUrlSuffix: "products", filename: "products-load-all-for-eligibility-criteria") + let pagedItems = try await itemProvider.providePointOfSaleItems() + + // Then + let expectedItems = pagedItems.items + XCTAssertEqual(expectedItems.count, expectedNumberOfItems) + + guard case .simpleProduct(let firstEligibleSimpleProduct) = expectedItems.first, + case .simpleProduct(let secondEligibleSimpleProduct) = expectedItems.last else { + return XCTFail("Expected \(expectedNumberOfItems) eligible items. Got \(expectedItems.count) instead.") + } + XCTAssertEqual(firstEligibleSimpleProduct.name, expectedItemNames.first) + XCTAssertEqual(secondEligibleSimpleProduct.name, expectedItemNames.last) + } + + // MARK: - Query Parameters + + func test_providePointOfSaleItems_sets_types_parameters_to_simple_only() async throws { + // Given + let itemProvider = PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + network: network, + isVariableProductsFeatureEnabled: false) + + // When + _ = try? await itemProvider.providePointOfSaleItems() + + // Then + XCTAssertEqual(network.queryParametersDictionary?["include_types"] as? String, "simple") + } + + func test_providePointOfSaleItems_sets_types_parameters_correctly_when_variable_products_feature_is_enabled() async throws { + // Given + let itemProvider = PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + network: network, + isVariableProductsFeatureEnabled: true) + + // When + _ = try? await itemProvider.providePointOfSaleItems() + + // Then + XCTAssertEqual(network.queryParametersDictionary?["include_types"] as? String, "simple,variable") + } + + func test_providePointOfSaleVariationItems_returns_variations_when_load_succeeds() async throws { + // Given + let itemProvider = PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + network: network, + isVariableProductsFeatureEnabled: true) + let parentProductID: Int64 = 123 + + // When + network.simulateResponse(requestUrlSuffix: "products/\(parentProductID)/variations", filename: "product-variations-load-all") + let pagedVariations = try await itemProvider.providePointOfSaleVariationItems( + for: .init( + id: .init(), + name: "Tea", + productImageSource: nil, + productID: parentProductID, + allAttributes: [ + .init( + siteID: siteID, + attributeID: 0, + name: "Shape", + position: 1, + visible: true, + variation: true, + options: ["Marble", "Heart"] + ), + .init( + siteID: siteID, + attributeID: 0, + name: "Flavor", + position: 2, + visible: true, + variation: true, + options: ["fruity", "nuts"] + ), + .init( + siteID: siteID, + attributeID: 0, + name: "Darkness", + position: 3, + visible: true, + variation: true, + options: ["99%", "87%"] + ), + .init( + siteID: siteID, + attributeID: 0, + name: "Size", + position: 4, + visible: true, + variation: true, + options: ["6 piece"] + ) + ] + ), + pageNumber: 1 + ) + + // Then + let variations = pagedVariations.items + + XCTAssertFalse(variations.isEmpty) + let firstVariation = try XCTUnwrap(variations.first) + guard case let .variation(firstVariation) = firstVariation else { + return XCTFail("Variation is expected.") + } + XCTAssertEqual( + firstVariation.name, + "marble - nuts - 99% - \(String.localizedStringWithFormat(VariationAttributeViewModel.Localization.anyAttributeFormat, "Size"))" + ) + XCTAssertEqual(firstVariation.formattedPrice, "$12.00") + XCTAssertEqual(firstVariation.price, "12") + XCTAssertEqual(firstVariation.productImageSource, + "https://i0.wp.com/funtestingusa.wpcomstaging.com/wp-content/uploads/2019/11/img_0002-1.jpeg?fit=4288%2C2848&ssl=1") + XCTAssertEqual(firstVariation.productID, parentProductID) + XCTAssertEqual(firstVariation.productVariationID, 1275) + } + + func test_providePointOfSaleVariationItems_throws_error_when_variations_load_fails() async throws { + // Given + let itemProvider = PointOfSaleItemService(siteID: siteID, + currencySettings: currencySettings, + network: network, + isVariableProductsFeatureEnabled: true) + let parentProductID: Int64 = 123 + let expectedError = PointOfSaleItemServiceError.requestFailed + + // When + network.simulateError(requestUrlSuffix: "products/\(parentProductID)/variations", error: expectedError) + + // Then + do { + _ = try await itemProvider.providePointOfSaleVariationItems( + for: .init( + id: .init(), + name: "Tea", + productImageSource: nil, + productID: parentProductID, + allAttributes: [] + ), + pageNumber: 1 + ) + XCTFail("An error is expected.") + } catch { + XCTAssertEqual(error as? PointOfSaleItemServiceError, expectedError) + } + } +} diff --git a/Yosemite/YosemiteTests/SnapshotsProvider/FetchResultSnapshotsProviderTests.swift b/Yosemite/YosemiteTests/SnapshotsProvider/FetchResultSnapshotsProviderTests.swift index 9e73bd8780e..f00edfc3998 100644 --- a/Yosemite/YosemiteTests/SnapshotsProvider/FetchResultSnapshotsProviderTests.swift +++ b/Yosemite/YosemiteTests/SnapshotsProvider/FetchResultSnapshotsProviderTests.swift @@ -268,9 +268,9 @@ final class FetchResultSnapshotsProviderTests: XCTestCase { let zanzaInDerived = derivedStorage.loadObject(ofType: StorageAccount.self, with: zanza.objectID)! zanzaInDerived.username = "Zanza Lockman" - derivedStorage.saveIfNeeded() - - exp.fulfill() + self.storageManager.saveDerivedType(derivedStorage: derivedStorage, { + exp.fulfill() + }) } } diff --git a/Yosemite/YosemiteTests/Stores/AccountStoreTests.swift b/Yosemite/YosemiteTests/Stores/AccountStoreTests.swift index ed0f4db60f2..d6858d00bfe 100644 --- a/Yosemite/YosemiteTests/Stores/AccountStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/AccountStoreTests.swift @@ -884,6 +884,56 @@ final class AccountStoreTests: XCTestCase { XCTAssertTrue(result.isFailure) XCTAssertEqual(result.failure as NSError?, error) } + + // MARK: - updateNotificationSettings + + func test_updateNotificationSettings_returns_success_upon_success() { + // Given + let network = MockNetwork() + let accountRemote = MockAccountRemote() + accountRemote.whenUpdatingNotificationSettings(thenReturn: .success(())) + let accountStore = AccountStore(dispatcher: dispatcher, + storageManager: storageManager, + network: network, + remote: accountRemote) + + // When + let result: Result = waitFor { promise in + let notificationSettings = NotificationSettings(deviceID: 132, enabledSites: [23], disabledSites: [44, 66]) + let action = AccountAction.updateNotificationSettings(notificationSettings: notificationSettings) { result in + promise(result) + } + accountStore.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + } + + func test_updateNotificationSettings_relays_error_upon_failure() { + // Given + let network = MockNetwork() + let accountRemote = MockAccountRemote() + let error = NSError(domain: "notifications", code: 33) + accountRemote.whenUpdatingNotificationSettings(thenReturn: .failure(error)) + let accountStore = AccountStore(dispatcher: dispatcher, + storageManager: storageManager, + network: network, + remote: accountRemote) + + // When + let result: Result = waitFor { promise in + let notificationSettings = NotificationSettings(deviceID: 132, enabledSites: [23], disabledSites: [44, 66]) + let action = AccountAction.updateNotificationSettings(notificationSettings: notificationSettings) { result in + promise(result) + } + accountStore.onAction(action) + } + + // Then + XCTAssertTrue(result.isFailure) + XCTAssertEqual(result.failure as NSError?, error) + } } // MARK: - Private Methods diff --git a/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift b/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift index da05a58046d..16fd8d5f696 100644 --- a/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift +++ b/Yosemite/YosemiteTests/Stores/AppSettings/InAppFeedbackCardVisibilityUseCaseTests.swift @@ -157,7 +157,6 @@ final class InAppFeedbackCardVisibilityUseCaseTests: XCTestCase { func test_orderFormShippingLines_shouldBeVisible_is_true_if_feedback_status_is_pending() throws { // Given - let lastFeedbackDate = try date(from: "2020-11-06T00:00:00Z") let currentDate = try date(from: "2020-11-12T23:59:59Z") fileManager.whenRetrievingAttributesOfItem(atPath: try documentDirectoryURL().path, thenReturn: [:]) @@ -174,7 +173,6 @@ final class InAppFeedbackCardVisibilityUseCaseTests: XCTestCase { func test_orderFormShippingLines_shouldBeVisible_is_false_if_feedback_status_is_dismissed() throws { // Given - let lastFeedbackDate = try date(from: "2020-11-06T00:00:00Z") let currentDate = try date(from: "2020-11-12T23:59:59Z") fileManager.whenRetrievingAttributesOfItem(atPath: try documentDirectoryURL().path, thenReturn: [:]) diff --git a/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests+OrderFilterHistory.swift b/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests+OrderFilterHistory.swift new file mode 100644 index 00000000000..3a42ccd1b05 --- /dev/null +++ b/Yosemite/YosemiteTests/Stores/AppSettingsStoreTests+OrderFilterHistory.swift @@ -0,0 +1,172 @@ +import Testing +@testable import Yosemite +@testable import Storage + +struct AppSettingsStoreTests_OrderFilterHistory { + + /// Mock Dispatcher! + /// + private var dispatcher: Dispatcher! + + /// Mock Storage: InMemory + /// + private var storageManager: MockStorageManager! + + /// Mock File Storage: Load data in memory + /// + private var fileStorage: MockInMemoryStorage! + + /// Mock General Settings Storage: Load data in memory + /// + private var generalAppSettings: GeneralAppSettingsStorage! + + init() { + dispatcher = Dispatcher() + storageManager = MockStorageManager() + fileStorage = MockInMemoryStorage() + generalAppSettings = GeneralAppSettingsStorage(fileStorage: fileStorage) + } + + @MainActor + @Test func loadOrderFilterHistory_returns_correct_results_after_upserting() async throws { + // Given + let siteID1: Int64 = 123 + let filter1 = createMockFilter(siteID: siteID1) + + let siteID2: Int64 = 34 + let filter2 = createMockFilter(siteID: siteID2) + + let subject = AppSettingsStore(dispatcher: dispatcher!, + storageManager: storageManager!, + fileStorage: fileStorage!, + generalAppSettings: generalAppSettings!) + + // Confidence check + await #expect(throws: AppSettingsStoreErrors.noOrderFilterHistory) { + _ = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID1) { result in + continuation.resume(with: result) + }) + } + } + + // When inserting two filters for two sites + try await insertMockFilter(filter: filter1, using: subject) + try await insertMockFilter(filter: filter2, using: subject) + + // Then + let resultAfterWritingAction = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID1) { result in + continuation.resume(with: result) + }) + } + #expect(resultAfterWritingAction == [filter1]) + } + + @MainActor + @Test func loadOrderFilterHistory_returns_correct_results_after_removing_filter() async throws { + // Given + let siteID1: Int64 = 123 + let filter1 = createMockFilter(siteID: siteID1, orderStatuses: [.pending]) + let filter2 = createMockFilter(siteID: siteID1, orderStatuses: [.completed]) + + let subject = AppSettingsStore(dispatcher: dispatcher!, + storageManager: storageManager!, + fileStorage: fileStorage!, + generalAppSettings: generalAppSettings!) + + try await insertMockFilter(filter: filter1, using: subject) + try await insertMockFilter(filter: filter2, using: subject) + + // Confidence check + let initialResult = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID1) { result in + continuation.resume(with: result) + }) + } + #expect(initialResult == [filter1, filter2]) + + // When + let error = await withCheckedContinuation { continuation in + subject.onAction(AppSettingsAction.removeFromOrderFilterHistory(filter: filter1, onCompletion: { error in + continuation.resume(returning: error) + })) + } + try #require(error == nil) + + // Then + let resultAfterRemoval = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID1) { result in + continuation.resume(with: result) + }) + } + #expect(resultAfterRemoval == [filter2]) + } + + @MainActor + @Test func resetOrderFilterHistory_clears_all_persisted_history() async throws { + // Given + let siteID1: Int64 = 123 + let filter1 = createMockFilter(siteID: siteID1) + + let siteID2: Int64 = 34 + let filter2 = createMockFilter(siteID: siteID2) + + let subject = AppSettingsStore(dispatcher: dispatcher!, + storageManager: storageManager!, + fileStorage: fileStorage!, + generalAppSettings: generalAppSettings!) + + try await insertMockFilter(filter: filter1, using: subject) + try await insertMockFilter(filter: filter2, using: subject) + + // When + let error = await withCheckedContinuation { continuation in + subject.onAction(AppSettingsAction.resetOrderFilterHistory(siteID: siteID1, onCompletion: { error in + continuation.resume(returning: error) + })) + } + try #require(error == nil) + + // Then + let site1Filters = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID1) { result in + continuation.resume(with: result) + }) + } + #expect(site1Filters == []) + + let site2Filters = try await withCheckedThrowingContinuation { continuation in + subject.onAction(AppSettingsAction.loadOrderFilterHistory(siteID: siteID2) { result in + continuation.resume(with: result) + }) + } + #expect(site2Filters == [filter2]) + } +} + +private extension AppSettingsStoreTests_OrderFilterHistory { + func createMockFilter(siteID: Int64, + orderStatuses: [OrderStatusEnum] = [.pending, .completed]) -> StoredOrderSettings.Setting { + let orderStatuses = orderStatuses + let startDate = Date().yearStart + let endDate = Date().yearEnd + let dateRange = OrderDateRangeFilter(filter: .custom, startDate: startDate, endDate: endDate) + let productFilter = FilterOrdersByProduct(id: 1, name: "Sample product") + let customerFilter = CustomerFilter(customer: Customer.fake().copy(customerID: 1)) + return StoredOrderSettings.Setting(siteID: siteID, + orderStatusesFilter: orderStatuses, + dateRangeFilter: dateRange, + productFilter: productFilter, + customerFilter: customerFilter) + } + + func insertMockFilter(filter: StoredOrderSettings.Setting, using store: AppSettingsStore) async throws { + let error = await withCheckedContinuation { continuation in + store.onAction(AppSettingsAction.upsertOrderFilterHistory(filter: filter, onCompletion: { error in + continuation.resume(returning: error) + })) + } + try #require(error == nil) + } +} diff --git a/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift b/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift index a2b29873464..3f247db2b43 100644 --- a/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/MediaStoreTests.swift @@ -103,17 +103,17 @@ final class MediaStoreTests: XCTestCase { func test_retrieveMediaLibrary_returns_media_list() throws { // Given network.simulateResponse(requestUrlSuffix: "media", filename: "media-library") - let expectedMedia = Media(mediaID: 2352, - date: date(with: "2020-02-21T12:15:38+08:00"), + let expectedMedia = Media(mediaID: 22, + date: date(with: "2021-11-22T01:55:57"), fileExtension: "jpeg", - filename: "img_0002-8.jpeg", + filename: "2021/11/img_0111-2-scaled.jpeg", mimeType: "image/jpeg", - src: "https://test.com/wp-content/uploads/2020/02/img_0002-8.jpeg", - thumbnailURL: "https://test.com/wp-content/uploads/2020/02/img_0002-8-150x150.jpeg", - name: "DSC_0010", - alt: "", - height: nil, - width: nil) + src: "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-scaled.jpeg", + thumbnailURL: "https://ninja.media/wp-content/uploads/2021/11/img_0111-2-150x150.jpeg", + name: "img_0111-2", + alt: "Floral", + height: 1920, + width: 2560) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -131,7 +131,7 @@ final class MediaStoreTests: XCTestCase { // Then let mediaItems = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaItems.count, 5) + XCTAssertEqual(mediaItems.count, 3) XCTAssertEqual(mediaItems.first, expectedMedia) } @@ -141,8 +141,8 @@ final class MediaStoreTests: XCTestCase { // Given network.simulateResponse(requestUrlSuffix: "media", filename: "media-library") - let expectedMedia = Media(mediaID: 2348, - date: date(with: "2020-02-21T11:58:24+08:00"), + let expectedMedia = Media(mediaID: 20, + date: date(with: "2021-11-22T01:55:57"), fileExtension: "jpeg", filename: "img_0111-1-12-тест.jpeg", mimeType: "image/jpeg", @@ -150,8 +150,8 @@ final class MediaStoreTests: XCTestCase { thumbnailURL: "https://test.com/wp-content/uploads/2020/02/img_0111-1-12-тест-150x150.jpeg", name: "img_0111-1", alt: "", - height: nil, - width: nil) + height: 1708, + width: 2560) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network) @@ -169,7 +169,7 @@ final class MediaStoreTests: XCTestCase { // Then let mediaItems = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaItems.count, 5) + XCTAssertEqual(mediaItems.count, 3) XCTAssertTrue(mediaItems.contains(expectedMedia)) } @@ -194,8 +194,8 @@ final class MediaStoreTests: XCTestCase { } // Then - let error = try XCTUnwrap(result.failure as? DotcomError) - XCTAssertEqual(error, .unauthorized) + let error = try XCTUnwrap(result.failure) + XCTAssertTrue(error is DecodingError) } /// Verifies that `MediaAction.retrieveMediaLibrary` returns an error whenever there is no backend response. @@ -251,7 +251,7 @@ final class MediaStoreTests: XCTestCase { let siteID = WooConstants.placeholderSiteID let remote = MockMediaRemote() let media = WordPressMedia.fake() - remote.whenLoadingMediaLibraryFromWordPressSite(siteID: siteID, thenReturn: .success([media])) + remote.whenLoadingMediaLibrary(siteID: siteID, thenReturn: .success([media])) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, @@ -269,7 +269,7 @@ final class MediaStoreTests: XCTestCase { } // Then - XCTAssertEqual(remote.invocations, [.loadMediaLibraryFromWordPressSite(siteID: siteID)]) + XCTAssertEqual(remote.invocations, [.loadMediaLibrary(siteID: siteID)]) let mediaList = try XCTUnwrap(result.get()) XCTAssertEqual(mediaList, [media.toMedia()]) @@ -280,7 +280,7 @@ final class MediaStoreTests: XCTestCase { // Given let remote = MockMediaRemote() let media = WordPressMedia.fake() - remote.whenLoadingMediaLibraryFromWordPressSite(siteID: sampleSiteID, thenReturn: .success([media])) + remote.whenLoadingMediaLibrary(siteID: sampleSiteID, thenReturn: .success([media])) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, @@ -300,7 +300,7 @@ final class MediaStoreTests: XCTestCase { } // Then - XCTAssertEqual(remote.invocations, [.loadMediaLibraryFromWordPressSite(siteID: sampleSiteID)]) + XCTAssertEqual(remote.invocations, [.loadMediaLibrary(siteID: sampleSiteID)]) let mediaList = try XCTUnwrap(result.get()) XCTAssertEqual(mediaList, [media.toMedia()]) @@ -310,7 +310,7 @@ final class MediaStoreTests: XCTestCase { func test_retrieveMediaLibrary_from_jcp_site_returns_error_upon_empty_response() throws { // Given let remote = MockMediaRemote() - remote.whenLoadingMediaLibraryFromWordPressSite(siteID: sampleSiteID, thenReturn: .failure(DotcomError.unauthorized)) + remote.whenLoadingMediaLibrary(siteID: sampleSiteID, thenReturn: .failure(DotcomError.unauthorized)) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, @@ -330,7 +330,7 @@ final class MediaStoreTests: XCTestCase { } // Then - XCTAssertEqual(remote.invocations, [.loadMediaLibraryFromWordPressSite(siteID: sampleSiteID)]) + XCTAssertEqual(remote.invocations, [.loadMediaLibrary(siteID: sampleSiteID)]) let error = try XCTUnwrap(result.failure as? DotcomError) XCTAssertEqual(error, .unauthorized) @@ -640,8 +640,8 @@ final class MediaStoreTests: XCTestCase { func test_updateProductID_returns_media() throws { // Given let remote = MockMediaRemote() - let media = Media.fake() - remote.whenUpdatingProductID(siteID: sampleSiteID, thenReturn: .success(media)) + let wordPressMedia = WordPressMedia.fake() + remote.whenUpdatingProductID(siteID: sampleSiteID, thenReturn: .success(wordPressMedia)) let mediaStore = MediaStore(dispatcher: dispatcher, storageManager: storageManager, network: network, @@ -658,7 +658,7 @@ final class MediaStoreTests: XCTestCase { // Then let mediaFromResult = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaFromResult, media) + XCTAssertEqual(mediaFromResult, wordPressMedia.toMedia()) } /// Verifies that `MediaAction.updateProductID` returns an error whenever there is an error response from the backend. @@ -687,88 +687,6 @@ final class MediaStoreTests: XCTestCase { XCTAssertEqual(error, .unauthorized) } - /// Verifies that `MediaAction.updateProductID` returns the expected response while connecting to site with placeholder site ID. - /// - func test_updateProductID_returns_media_when_connecting_to_site_with_placeholder_site_id() throws { - // Given - let siteID = WooConstants.placeholderSiteID - let remote = MockMediaRemote() - let media = WordPressMedia.fake() - remote.whenUpdatingProductIDToWordPressSite(siteID: siteID, thenReturn: .success(media)) - let mediaStore = MediaStore(dispatcher: dispatcher, - storageManager: storageManager, - network: network, - remote: remote) - // When - let result: Result = waitFor { promise in - let action = MediaAction.updateProductID(siteID: siteID, - productID: self.sampleProductID, - mediaID: self.sampleMediaID) { result in - promise(result) - } - mediaStore.onAction(action) - } - - // Then - let mediaFromResult = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaFromResult, media.toMedia()) - } - - /// Verifies that `MediaAction.updateProductID` returns the expected response while connecting to JCP sites. - /// - func test_updateProductIDToWordPressSite_returns_media() throws { - // Given - let remote = MockMediaRemote() - let media = WordPressMedia.fake() - remote.whenUpdatingProductIDToWordPressSite(siteID: sampleSiteID, thenReturn: .success(media)) - let mediaStore = MediaStore(dispatcher: dispatcher, - storageManager: storageManager, - network: network, - remote: remote) - insertJCPSiteToStorage(siteID: sampleSiteID) - - // When - let result: Result = waitFor { promise in - let action = MediaAction.updateProductID(siteID: self.sampleSiteID, - productID: self.sampleProductID, - mediaID: self.sampleMediaID) { result in - promise(result) - } - mediaStore.onAction(action) - } - - // Then - let mediaFromResult = try XCTUnwrap(result.get()) - XCTAssertEqual(mediaFromResult, media.toMedia()) - } - - /// Verifies that `MediaAction.updateProductID` while connecting to JCP sites returns an error whenever there is an error response from the backend. - /// - func test_updateProductIDToWordPressSite_returns_error_upon_response_error() throws { - // Given - let remote = MockMediaRemote() - remote.whenUpdatingProductIDToWordPressSite(siteID: sampleSiteID, thenReturn: .failure(DotcomError.unauthorized)) - let mediaStore = MediaStore(dispatcher: dispatcher, - storageManager: storageManager, - network: network, - remote: remote) - insertJCPSiteToStorage(siteID: sampleSiteID) - - // When - let result: Result = waitFor { promise in - let action = MediaAction.updateProductID(siteID: self.sampleSiteID, - productID: self.sampleProductID, - mediaID: self.sampleMediaID) { result in - promise(result) - } - mediaStore.onAction(action) - } - - // Then - let error = try XCTUnwrap(result.failure as? DotcomError) - XCTAssertEqual(error, .unauthorized) - } - func test_toMedia_converts_rendered_title_to_file_name_if_media_detail_is_not_available() { // Given let wpMedia = WordPressMedia.fake().copy(slug: "test", @@ -832,7 +750,7 @@ private extension MediaStoreTests { } func date(with dateString: String) -> Date { - guard let date = DateFormatter.Defaults.iso8601.date(from: dateString) else { + guard let date = DateFormatter.Defaults.dateTimeFormatter.date(from: dateString) else { return Date() } return date diff --git a/Yosemite/YosemiteTests/Stores/NotificationStoreTests.swift b/Yosemite/YosemiteTests/Stores/NotificationStoreTests.swift index 97cef08d3c7..9108b7f2789 100644 --- a/Yosemite/YosemiteTests/Stores/NotificationStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/NotificationStoreTests.swift @@ -31,9 +31,6 @@ class NotificationStoreTests: XCTestCase { dispatcher = Dispatcher() storageManager = MockStorageManager() network = MockNetwork() - - // Need to nuke this in-between tests otherwise some will randomly fail - NotificationStore.resetSharedDerivedStorage() } diff --git a/Yosemite/YosemiteTests/Stores/Order/OrdersUpsertUseCaseTests.swift b/Yosemite/YosemiteTests/Stores/Order/OrdersUpsertUseCaseTests.swift index c9b3dac8905..bdd854d7a77 100644 --- a/Yosemite/YosemiteTests/Stores/Order/OrdersUpsertUseCaseTests.swift +++ b/Yosemite/YosemiteTests/Stores/Order/OrdersUpsertUseCaseTests.swift @@ -8,7 +8,7 @@ import Storage final class OrdersUpsertUseCaseTests: XCTestCase { private let defaultSiteID: Int64 = 10 - private var storageManager: StorageManagerType! + private var storageManager: MockStorageManager! private var viewStorage: StorageType { storageManager.viewStorage } @@ -356,11 +356,11 @@ final class OrdersUpsertUseCaseTests: XCTestCase { let productStore = ProductStore(dispatcher: dispatcher, storageManager: storageManager, network: network) // When - DispatchQueue.global(qos: .background).async { + backgroundContext.perform { orderUseCase.upsert([order]) productStore.upsertStoredProducts(readOnlyProducts: [product], in: backgroundContext) - backgroundContext.saveIfNeeded() } + storageManager.saveDerivedType(derivedStorage: backgroundContext, {}) // Then self.waitUntil { diff --git a/Yosemite/YosemiteTests/Stores/ProductStoreTests.swift b/Yosemite/YosemiteTests/Stores/ProductStoreTests.swift index 7409d920fcc..2ece6794770 100644 --- a/Yosemite/YosemiteTests/Stores/ProductStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/ProductStoreTests.swift @@ -2435,6 +2435,8 @@ final class ProductStoreTests: XCTestCase { .fake().copy(sku: "chocobars", purchasable: true) ])) + remote.whenSearchingProductsByGlobalUniqueIdentifier(identifier: "chocobars", thenReturn: .success([])) + // When let result = waitFor { promise in store.onAction(ProductAction.retrieveFirstPurchasableItemMatchFromIdentifier(siteID: self.sampleSiteID, diff --git a/Yosemite/YosemiteTests/Stores/SystemStatusStoreTests.swift b/Yosemite/YosemiteTests/Stores/SystemStatusStoreTests.swift index d1e410c77f1..95c00e2387b 100644 --- a/Yosemite/YosemiteTests/Stores/SystemStatusStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/SystemStatusStoreTests.swift @@ -61,7 +61,7 @@ final class SystemStatusStoreTests: XCTestCase { let store = SystemStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network) // When - let result = waitFor { promise in + _ = waitFor { promise in store.onAction(SystemStatusAction.synchronizeSystemInformation(siteID: self.sampleSiteID) { result in promise(result) }) @@ -205,7 +205,7 @@ final class SystemStatusStoreTests: XCTestCase { let store = SystemStatusStore(dispatcher: dispatcher, storageManager: storageManager, network: network) // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in let action = SystemStatusAction.fetchSystemStatusReport(siteID: self.sampleSiteID) { result in promise(result) } diff --git a/Yosemite/YosemiteTests/Stores/WooShippingStoreTests.swift b/Yosemite/YosemiteTests/Stores/WooShippingStoreTests.swift index 80e1a3ede8f..25ee508c8f2 100644 --- a/Yosemite/YosemiteTests/Stores/WooShippingStoreTests.swift +++ b/Yosemite/YosemiteTests/Stores/WooShippingStoreTests.swift @@ -59,8 +59,7 @@ final class WooShippingStoreTests: XCTestCase { func test_createPackage_returns_error_on_failure() throws { // Given let remote = MockWooShippingRemote() - let error = DotcomError.unknown(code: "duplicate_custom_package_names_of_existing_packages", - message: "At least one of the new custom packages has the same name as existing packages.") + let error = PackageCreationError.duplicateCustomPackageNames remote.whenCreatePackage(siteID: sampleSiteID, thenReturn: .failure(error)) let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) @@ -76,7 +75,128 @@ final class WooShippingStoreTests: XCTestCase { // Then XCTAssertTrue(result.isFailure) - XCTAssertEqual(result.failure, .duplicatePackageNames) + } + + func test_createPackage_when_successful_then_upserts_packages_into_storage() throws { + // Given + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "packages", filename: "wooshipping-create-package-success") + storageManager.insertSamplePackages(readOnlyPackages: .init(siteID: sampleSiteID, + customPackages: [], + savedPredefinedPackages: [], + allPredefinedOptions: [sampleCarrierPredefinedOptions()])) + + // Confidence check + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: StorageWooShippingCustomPackage.self), 0) + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: StorageWooShippingSavedPredefinedPackage.self), 0) + + // When + let onSuccess: Bool = waitFor { promise in + let action = WooShippingAction.createPackage(siteID: self.sampleSiteID, + customPackage: .fake(), + predefinedOption: .fake()) { result in + promise(result.isSuccess) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(onSuccess) + let storedPackages = try XCTUnwrap(storageManager.viewStorage.firstObject(ofType: StorageWooShippingPackagesResponse.self)).toReadOnly() + XCTAssertEqual(storedPackages.siteID, sampleSiteID) + XCTAssertEqual(storedPackages.customPackages.count, 2) + XCTAssertEqual(storedPackages.customPackages.first?.boxWeight, 0.25) + XCTAssertEqual(storedPackages.customPackages.last?.boxWeight, 0.25) + XCTAssertEqual(storedPackages.savedPredefinedPackages.count, 2) + } + + // MARK: `deletePackage` + + func test_deletePackage_returns_success_response() throws { + // Given + let remote = MockWooShippingRemote() + let response = WooShippingCreatePackageResponse.fake().copy(customPackages: [WooShippingCustomPackage.fake()]) + remote.whenDeletePackage(siteID: sampleSiteID, thenReturn: .success(response)) + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + + // When + let result: Result = waitFor { promise in + let action = WooShippingAction.deletePackage(siteID: self.sampleSiteID, + packageID: WooShippingCustomPackage.fake().id) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isSuccess) + let actualResponse = try result.get() + XCTAssertEqual(actualResponse, response) + } + + func test_deletePackage_returns_error_on_failure() throws { + // Given + let remote = MockWooShippingRemote() + let error = DotcomError.requestFailed + remote.whenDeletePackage(siteID: sampleSiteID, thenReturn: .failure(error)) + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + + // When + let result: Result = waitFor { promise in + let action = WooShippingAction.deletePackage(siteID: self.sampleSiteID, + packageID: WooShippingCustomPackage.fake().id) { result in + promise(result) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(result.isFailure) + } + + func test_deletePackage_when_successful_then_upserts_packages_into_storage() throws { + // Given + let remote = MockWooShippingRemote() + let response = WooShippingCreatePackageResponse.fake().copy(customPackages: [WooShippingCustomPackage.fake().copy(id: "2")], + predefinedOptions: [.init(id: "usps", predefinedPackageIDs: ["small_flat_box"])]) + remote.whenDeletePackage(siteID: sampleSiteID, thenReturn: .success(response)) + storageManager.insertSamplePackages(readOnlyPackages: .init(siteID: sampleSiteID, + customPackages: [WooShippingCustomPackage.fake().copy(id: "1"), + WooShippingCustomPackage.fake().copy(id: "2")], + savedPredefinedPackages: [.init(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "small_flat_box", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "")), + .init(groupTitle: "pri_flat_boxes", + providerID: "usps", + package: .init(id: "medium_flat_box_top", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: ""))], + allPredefinedOptions: [sampleCarrierPredefinedOptions()])) + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + + // When + let onSuccess: Bool = waitFor { promise in + let action = WooShippingAction.deletePackage(siteID: self.sampleSiteID, + packageID: WooShippingCustomPackage.fake().id) { result in + promise(result.isSuccess) + } + store.onAction(action) + } + + // Then + XCTAssertTrue(onSuccess) + let storedPackages = try XCTUnwrap(storageManager.viewStorage.firstObject(ofType: StorageWooShippingPackagesResponse.self)).toReadOnly() + XCTAssertEqual(storedPackages.siteID, sampleSiteID) + XCTAssertEqual(storedPackages.customPackages.count, 1) + XCTAssertEqual(storedPackages.savedPredefinedPackages.count, 1) } // MARK: `loadLabelRates` @@ -141,7 +261,7 @@ final class WooShippingStoreTests: XCTestCase { let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in let action = WooShippingAction.loadPackages(siteID: self.sampleSiteID) { result in promise(result) } @@ -154,15 +274,50 @@ final class WooShippingStoreTests: XCTestCase { XCTAssertEqual(actualResponse, response) } + func test_loadPackages_when_successful_then_upserts_packages_into_storage() throws { + // Given + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network) + network.simulateResponse(requestUrlSuffix: "packages", filename: "wooshipping-get-packages-success") + + // Confidence check + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: StorageWooShippingPackagesResponse.self), 0) + + // When + let onSuccess: Bool = waitFor { promise in + let action = WooShippingAction.loadPackages(siteID: self.sampleSiteID) { result in + promise(result.isSuccess) + } + store.onAction(action) + } + + let storedPackages = try XCTUnwrap(storageManager.viewStorage.firstObject(ofType: StorageWooShippingPackagesResponse.self)).toReadOnly() + + // Then + XCTAssertTrue(onSuccess) + XCTAssertEqual(storageManager.viewStorage.countObjects(ofType: StorageWooShippingPackagesResponse.self), 1) + XCTAssertEqual(storedPackages.siteID, sampleSiteID) + XCTAssertEqual(storedPackages.allPredefinedOptions.count, 2) + XCTAssertEqual(storedPackages.allPredefinedOptions.first?.predefinedOptions.count, 1) + XCTAssertEqual(storedPackages.allPredefinedOptions.first?.predefinedOptions.first?.predefinedPackages.count, 2) + XCTAssertEqual(storedPackages.customPackages.count, 1) + XCTAssertEqual(storedPackages.customPackages.first?.name, "Custom name") + XCTAssertEqual(storedPackages.customPackages.first?.boxWeight, 0.01) + XCTAssertEqual(storedPackages.customPackages.first?.id, "849225dc153") + XCTAssertEqual(storedPackages.customPackages.first?.type, .box) + XCTAssertEqual(storedPackages.customPackages.first?.dimensions, "12 x 12 x 12") + XCTAssertEqual(storedPackages.savedPredefinedPackages.count, 2) + XCTAssertTrue(storedPackages.savedPredefinedPackages.contains(where: { $0.package.id == "small_flat_box" })) + } + func test_loadPackages_returns_error_on_failure() throws { // Given let remote = MockWooShippingRemote() let expectedError = NetworkError.notFound() - remote.whenLoadPackages(siteID: sampleSiteID, thenReturn: .failure(expectedError)) + remote.whenLoadPackages(siteID: sampleSiteID, thenReturn: .failure(WooShippingLoadPackagesError.loadingFailed(error: expectedError))) let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) // When - let result: Result = waitFor { promise in + let result: Result = waitFor { promise in let action = WooShippingAction.loadPackages(siteID: self.sampleSiteID) { result in promise(result) } @@ -172,7 +327,7 @@ final class WooShippingStoreTests: XCTestCase { // Then XCTAssertTrue(result.isFailure) let error = try XCTUnwrap(result.failure) - XCTAssertEqual(error as? NetworkError, expectedError) + XCTAssertEqual(error, WooShippingLoadPackagesError.loadingFailed(error: expectedError)) } // MARK: `purchaseShippingLabel` @@ -396,6 +551,52 @@ final class WooShippingStoreTests: XCTestCase { // Then XCTAssertEqual(error as? NetworkError, expectedError) } + + func test_loadOriginAddresses_returns_addresses_on_success() { + // Given + let expectedAddresses: [WooShippingOriginAddress] = [WooShippingOriginAddress.fake()] + let remote = MockWooShippingRemote() + remote.whenOriginAddresses(siteID: sampleSiteID, thenReturn: .success(expectedAddresses)) + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + + // When + let addresses: [WooShippingOriginAddress] = waitFor { promise in + let action = WooShippingAction.loadOriginAddresses(siteID: self.sampleSiteID) { result in + guard let printData = try? result.get() else { + XCTFail("Error loading origin addresses for shipping label: \(String(describing: result.failure))") + return + } + promise(printData) + } + store.onAction(action) + } + + // Then + XCTAssertEqual(addresses, expectedAddresses) + } + + func test_loadOriginAddresses_returns_error_failure() { + // Given + let expectedError = NetworkError.timeout() + let remote = MockWooShippingRemote() + remote.whenOriginAddresses(siteID: sampleSiteID, thenReturn: .failure(expectedError)) + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + + // When + let error: Error = waitFor { promise in + let action = WooShippingAction.loadOriginAddresses(siteID: self.sampleSiteID) { result in + guard let printData = result.failure else { + XCTFail("Unexpected result when printing shipping label: \(result)") + return + } + promise(printData) + } + store.onAction(action) + } + + // Then + XCTAssertEqual(error as? NetworkError, expectedError) + } } private extension WooShippingStoreTests { @@ -421,4 +622,40 @@ private extension WooShippingStoreTests { deliveryDays: 7, deliveryDateGuaranteed: false) } + + func sampleCustomPackage() -> WooShippingCustomPackage { + WooShippingCustomPackage(id: "849225dc153", + name: "Custom name", + rawType: "box", + dimensions: "12 x 12 x 12", + boxWeight: 0.01) + } + + func sampleCarrierPredefinedOptions() -> WooShippingCarrierPredefinedOptions { + WooShippingCarrierPredefinedOptions(carrierID: "usps", + predefinedOptions: [.init(title: "pri_flat_boxes", + providerID: "usps", + predefinedPackages: [.init(id: "small_flat_box", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "")]), + .init(title: "pri_flat_envelopes", + providerID: "usps", + predefinedPackages: [.init(id: "flat_envelope", + name: "", + isLetter: true, + dimensions: "", + boxWeight: "", + groupId: "")]), + .init(title: "pri_flat_boxes", + providerID: "usps", + predefinedPackages: [.init(id: "medium_flat_box_top", + name: "", + isLetter: false, + dimensions: "", + boxWeight: "", + groupId: "")])]) + } } diff --git a/Yosemite/YosemiteTests/Tools/POS/POSCartItemTests.swift b/Yosemite/YosemiteTests/Tools/POS/POSCartItemTests.swift index 1557665f7d6..49ae678436f 100644 --- a/Yosemite/YosemiteTests/Tools/POS/POSCartItemTests.swift +++ b/Yosemite/YosemiteTests/Tools/POS/POSCartItemTests.swift @@ -101,7 +101,12 @@ struct POSCartItemTests { } private func makeCartItem(id: UUID = UUID(), quantity: Decimal, matching: [OrderItem] = [], matcher: ((OrderItem) -> Bool)? = nil) -> POSCartItem { - return POSCartItem(item: MockPOSItem(name: "", id: id, formattedPrice: "", productImageSource: nil, orderItemsToMatch: matching, matcher: matcher), + return POSCartItem(item: MockPOSOrderableItem(name: "", + id: id, + formattedPrice: "", + productImageSource: nil, + orderItemsToMatch: matching, + matcher: matcher), quantity: quantity) } } diff --git a/Yosemite/YosemiteTests/Tools/POS/POSOrderServiceTests.swift b/Yosemite/YosemiteTests/Tools/POS/POSOrderServiceTests.swift index e08d5df3ff2..afdbc340698 100644 --- a/Yosemite/YosemiteTests/Tools/POS/POSOrderServiceTests.swift +++ b/Yosemite/YosemiteTests/Tools/POS/POSOrderServiceTests.swift @@ -3,17 +3,14 @@ import Testing struct POSOrderServiceTests { let sut: POSOrderService - let mockReceiptsRemote: MockReceiptsOrderRemote let mockOrdersRemote: MockPOSOrdersRemote init() { - let mockReceiptsRemote = MockReceiptsOrderRemote() let mockOrdersRemote = MockPOSOrdersRemote() - self.mockReceiptsRemote = mockReceiptsRemote self.mockOrdersRemote = mockOrdersRemote - self.sut = POSOrderService(siteID: 123, ordersRemote: mockOrdersRemote, receiptsRemote: mockReceiptsRemote) + self.sut = POSOrderService(siteID: 123, ordersRemote: mockOrdersRemote) } @Test @@ -118,7 +115,7 @@ private func makePOSCartItem( productID: Int64, quantity: Decimal) -> POSCartItem { return POSCartItem( - item: POSProduct(id: UUID(), + item: POSSimpleProduct(id: UUID(), name: "", formattedPrice: "", productID: productID, diff --git a/Yosemite/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift b/Yosemite/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift new file mode 100644 index 00000000000..9d3a7211e2e --- /dev/null +++ b/Yosemite/YosemiteTests/Tools/POS/POSReceiptServiceTests.swift @@ -0,0 +1,48 @@ +import Testing +import XCTest +@testable import Yosemite + +struct POSReceiptServiceTests { + let sut: POSReceiptService + let receiptsRemote: MockPOSReceiptsRemote + + init() { + let receiptsRemote = MockPOSReceiptsRemote() + self.receiptsRemote = receiptsRemote + + self.sut = POSReceiptService(siteID: 123, receiptsRemote: receiptsRemote) + } + + @Test + func sendReceipt_calls_remote_with_correct_parameters() async throws { + // Given + let order = Order.fake().copy(orderID: 456) + let email = "test@example.com" + + // When + try await sut.sendReceipt(order: order, recipientEmail: email) + + // Then + #expect(receiptsRemote.sendReceiptCalled) + #expect(receiptsRemote.spySiteID == 123) + #expect(receiptsRemote.spyOrderID == 456) + } + + @Test + func sendReceipt_when_remote_fails_throws_error() async { + // Given + let order = Order.fake() + receiptsRemote.shouldThrowError = NSError(domain: "test", code: 0) + + // When/Then + do { + try await sut.sendReceipt(order: order, recipientEmail: "test@example.com") + XCTFail("Expected error to be thrown") + } catch { + guard case POSReceiptService.POSReceiptServiceError.sendReceiptFailed = error else { + XCTFail("Expected error .sendReceiptFailed, but got \(error)") + return + } + } + } +} diff --git a/config/Version.Public.xcconfig b/config/Version.Public.xcconfig index a9c80aff35b..d5912a7b028 100644 --- a/config/Version.Public.xcconfig +++ b/config/Version.Public.xcconfig @@ -1,4 +1,4 @@ CURRENT_PROJECT_VERSION = $VERSION_LONG MARKETING_VERSION = $VERSION_SHORT -VERSION_LONG = 21.2.0.0 -VERSION_SHORT = 21.2 +VERSION_LONG = 21.3.0.0 +VERSION_SHORT = 21.3 diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 3cb1f37e647..d256d008a9f 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -24,6 +24,10 @@ When `CoreDataManager` is requested a `viewContext`, it will provide the persis Notes: - For thread safety, do not send any `NSManagedObject` instance to the completion closure of `performAndSave`. There's an assertion to ensure at debug runtime this does not happen. - For performance reasons, please be mindful with fetch requests. Avoid making multiple fetch requests in for loops. This can be replaced by a single fetch request for a list of objects instead. +- For attributes of type Transformable, if the transformer is `NSSecureUnarchiveFromData`, make sure to input a `class` type in the Custom Class field in the Core Data model. Using a Swift type (e.g. [Int64] or [String]) would cause an error logged in Xcode 16. +- We have a launch argument `-enforce-core-data-write-in-background` enabled by default in the WooCommerce scheme. This will intentionally crash the app in the debug mode when one attempts to perform write operations in the main thread with the view context. +- We also enable `-com.apple.CoreData.ConcurrencyDebug` launch argument by default to get notified when there's a concurrency issue with our Core Data stack. Please keep an eye out for errors from Core Data in the console log and report any problems when you see them. +- When setting an attribute of type `Date`/`UUID`/`URI` as non-optional, a default value should be set or the property should be kept optional in Swift. More details [here](https://holko.pl/2017/09/18/surprising-non-optional-nsmanaged/). ## File storage The Storage module also exposes a protocol, called [`FileStorage`](../Storage/Storage/Protocols/FileStorage.swift) to abstract saving and reading data to and from local storage. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ab7e9dc91f7..49d8347c2a7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -667,7 +667,8 @@ platform :ios do version: version, release_notes_file_path: RELEASE_NOTES_PATH, release_assets: archive_zip_path.to_s, - prerelease: options[:beta_release] + prerelease: options[:beta_release], # Beta = Prerelease, Final = normal Release + is_draft: !options[:beta_release] # Beta = publish immediately, Final = Draft (only publish manually after Apple approval) ) sh("rm #{archive_zip_path}") @@ -1274,7 +1275,8 @@ lane :test_without_building do |options| workspace: WORKSPACE_PATH, scheme: TEST_SCHEME, device: options[:device], - reset_simulator: true, + ensure_devices_found: true, + force_quit_simulator: true, parallel_testing: parallel_testing_value, concurrent_workers: CONCURRENT_SIMULATORS, max_concurrent_simulators: CONCURRENT_SIMULATORS, diff --git a/fastlane/metadata/ar-SA/release_notes.txt b/fastlane/metadata/ar-SA/release_notes.txt index c4d933c3820..722a3fbaa2f 100644 --- a/fastlane/metadata/ar-SA/release_notes.txt +++ b/fastlane/metadata/ar-SA/release_notes.txt @@ -1 +1,3 @@ -انغمس في تجربة أكثر سلاسة على WooCommerce! لقد ضبطنا تطبيق Watch لعرض تفاصيل طلبك بشكل مثالي. إضافة إلى أننا أضفنا رسائل خطأ أكثر وضوحًا لإجراء عمليات ربط أدوات قراءة المدفوعات. بادر بالتحديث لتحسين الاطلاع على الأعمال من خلال سهولة الوصول إلى كل الأمور. +في غضون أسبوعين، قمنا بتعبئة هذا الإصدار. هناك دعم لمعرف المنتج العالمي GTIN، ويمكنك تحرير الحثّ على المبادرة في حملات Blaze. + +أصبح إعداد المتجر للمدفوعات الشخصية أسرع، وأصبحت عمليات التأكيد على الإيصالات المرسلة أكثر وضوحًا، كما أصبح اختبار ميزة Tap to Pay على iPhone أكثر سلاسة. إضافة إلى ذلك، قمنا بتحسين إحصاءات لوحة التحكم وقضينا على بعض الأخطاء. diff --git a/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/de-DE/release_notes.txt index 130bceaf5a1..2a5cc3a36ac 100644 --- a/fastlane/metadata/de-DE/release_notes.txt +++ b/fastlane/metadata/de-DE/release_notes.txt @@ -1 +1,3 @@ -Genieße ein reibungsloseres WooCommerce-Erlebnis! Wir haben die Watch-App optimiert, damit deine Bestelldetails einwandfrei angezeigt werden. Außerdem haben Fehlermeldungen bei Zahlungslesegeräten jetzt ein klareres Erscheinungsbild. Führe für ein besseres Erlebnis sowohl auf deiner Watch als auch auf deinem Smartphone ein Update durch. +Wir haben diese neue Version in nur zwei Wochen mit unzähligen Neuerungen ausgestattet! Es gibt nun einen GTIN-Support für die global eindeutige Produktkennung. Außerdem kannst du den Call-to-Action in Blaze-Kampagnen bearbeiten. + +Das Einrichten persönlicher Zahlungen im Shop gelingt nun noch schneller, die Bestätigung für den Versand von Belegen ist eindeutiger und der Test von „Tap to Pay“ auf dem iPhone ist jetzt noch nahtloser. Außerdem haben wir die Dashboard-Statistiken verbessert und einige Bugs behoben. diff --git a/fastlane/metadata/default/release_notes.txt b/fastlane/metadata/default/release_notes.txt index d2cb83c3132..cc22e8f345e 100644 --- a/fastlane/metadata/default/release_notes.txt +++ b/fastlane/metadata/default/release_notes.txt @@ -1 +1,3 @@ -Dive into a smoother WooCommerce experience! We’ve fine-tuned the Watch app to showcase your order details flawlessly. Plus, we've added clearer error messages for payment reader connections. Update for an even better business pulse on your wrist and in your pocket. +In just two weeks, we've jam-packed this release. There's GTIN global product identifier support, and you can edit the call-to-action in Blaze campaigns. + +Store setup for in-person payments is faster, receipt sent confirmations clearer, and testing Tap to Pay on iPhone is smoother. Plus, we've improved dashboard statistics and squashed some bugs. diff --git a/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/es-ES/release_notes.txt index ed88b766568..b13e671495b 100644 --- a/fastlane/metadata/es-ES/release_notes.txt +++ b/fastlane/metadata/es-ES/release_notes.txt @@ -1 +1,3 @@ -Sumérgete en una experiencia de WooCommerce más fluida. Hemos perfeccionado la aplicación Watch para que muestre los detalles de tu pedido a la perfección. Además, hemos añadido mensajes de error más claros para las conexiones de lectores de pagos. Actualízate para tener un pulso empresarial aún mejor en tu muñeca y en tu bolsillo. +En solo dos semanas, hemos llenado hasta los topes esta versión. Es compatible con el identificador global de producto GTIN y puedes editar la llamada a la acción en las campañas de Blaze. + +La configuración de la tienda para los pagos en persona es más rápida, las confirmaciones de recibo enviado son más claras y la opción Tap to Pay en iPhone es más fácil. Además, hemos mejorado las estadísticas del escritorio y eliminado algunos errores. diff --git a/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/fr-FR/release_notes.txt index 704e6cdc44e..195339513f3 100644 --- a/fastlane/metadata/fr-FR/release_notes.txt +++ b/fastlane/metadata/fr-FR/release_notes.txt @@ -1 +1,3 @@ -Plongez-vous dans une expérience WooCommerce plus fluide ! Nous avons peaufiné l’application Watch pour mettre parfaitement en vitrine les détails de vos commandes. Par ailleurs, nous avons ajouté des messages d’erreur plus clairs pour les connexions de lecteurs de paiement. Mettez à jour pour mieux prendre le pouls de votre activité à votre poignet et dans votre poche. +En seulement deux semaines, nous avons chargé à bloc cette version. L’identifiant de produit mondial GTIN est pris en charge et vous pouvez modifier l’appel à l’action dans les campagnes Blaze. + +La configuration de la boutique pour les paiements en personne est plus rapide, les confirmations d’envoi des reçus sont plus claires et le test d’Appuyer pour payer est plus fluide. Par ailleurs, nous avons amélioré les statistiques du tableau de bord et éliminé quelques bugs. diff --git a/fastlane/metadata/he/release_notes.txt b/fastlane/metadata/he/release_notes.txt index fd45aeb6edf..df8ad81b427 100644 --- a/fastlane/metadata/he/release_notes.txt +++ b/fastlane/metadata/he/release_notes.txt @@ -1 +1,3 @@ -אפשר ליהנות מחוויית שימוש חלקה יותר עם WooCommerce כבר עכשיו! שיפרנו את אפליקציית Watch כדי להציג את פרטי ההזמנה בצורה מדויקת. בנוסף, הוספנו הודעות שגיאה ברורות יותר לחיבורים של קורא כרטיסים לתשלומים. יש לעדכן כדי ליהנות מאפשרויות עסקיות טובות יותר על צג השעון או על מסך הטלפון. +בשבועיים בלבד הצלחנו ליצור גרסה גדושה בעדכונים. הוספנו תמיכה גלובלית במזהה מוצרים של GTIN וכעת אפשר לערוך את הקריאה לפעולה בקמפיינים של Blaze. + +תהליך ההגדרה מהיר יותר לתשלומים באופן אישי בחנות, האישור על שליחת קבלות ברור יותר והבדיקה של Tap To Pay ב-iPhone יעילה יותר. בנוסף, שיפרנו את הנתונים הסטטיסטיים בלוח הבקרה ומחצנו כמה באגים. diff --git a/fastlane/metadata/id/release_notes.txt b/fastlane/metadata/id/release_notes.txt index cba84705017..f9fbf47cd93 100644 --- a/fastlane/metadata/id/release_notes.txt +++ b/fastlane/metadata/id/release_notes.txt @@ -1 +1,3 @@ -Nikmati pengalaman menggunakan WooCommerce yang lebih baik! Kami sudah menyempurnakan aplikasi Watch agar menampilkan rincian pesanan dengan benar. Kami juga menambahkan pesan error untuk sambungan alat pembaca pembayaran. Perbarui untuk memantau perkembangan bisnis lebih saksama lewat smartwatch dan ponsel Anda. +Hanya dalam dua minggu, kami melengkapi rilis ini. Ada dukungan pengenal produk global GTIN dan Anda dapat menyunting call-to-action di kampanye Blaze. + +Penyiapan toko untuk pembayaran langsung kini lebih cepat, konfirmasi terkirimnya tanda terima lebih jelas, dan pengujian Ketuk untuk Bayar di iPhone lebih lancar. Kami juga menyempurnakan statistik dasbor dan mengatasi bug. diff --git a/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/it/release_notes.txt index f2cf686d536..f76010417e7 100644 --- a/fastlane/metadata/it/release_notes.txt +++ b/fastlane/metadata/it/release_notes.txt @@ -1 +1,3 @@ -Goditi un'esperienza WooCommerce più fluida. Abbiamo perfezionato l'app Watch per mostrare i dettagli degli ordini in modo impeccabile. Inoltre, abbiamo aggiunto messaggi di errore più chiari per i collegamenti con il lettore dei pagamenti. Effettua l'aggiornamento per avere sempre al polso e in tasca la situazione aggiornata della tua attività. +In sole due settimane abbiamo riempito questa release di contenuti. È disponibile il supporto per l'identificatore di prodotto globale GTIN ed è possibile modificare l'invito all'azione nelle campagne Blaze. + +L'impostazione del negozio per i pagamenti di persona è più veloce, le conferme di invio ricevute sono più chiare e il test Tap to Pay su iPhone è più fluido. Inoltre, abbiamo migliorato le statistiche della bacheca ed eliminato alcuni bug. diff --git a/fastlane/metadata/ja/release_notes.txt b/fastlane/metadata/ja/release_notes.txt index 41db60ae443..fb19d6e056f 100644 --- a/fastlane/metadata/ja/release_notes.txt +++ b/fastlane/metadata/ja/release_notes.txt @@ -1 +1,3 @@ -よりスムーズな WooCommerce エクスペリエンスをご確認ください ! Watch アプリに微調整を加え、注文詳細が問題なく表示されるようになりました。 さらに、支払いリーダーの接続に関するエラーメッセージをより明確にしました。 アップデートして、手首やポケットの中でビジネスにさらなる活力を加えましょう。 +わずか2週間で、このリリースに盛りだくさんの内容を詰め込みました。 GTIN グローバル商品識別子のサポートが追加され、Blaze キャンペーンで行動喚起 (CTA) を編集できるようになりました。 + +対面支払いのストア設定が迅速になり、領収書の送信確認がさらに明確になりました。また iPhone での「タップして支払う」をよりスムーズにテストしていただけるようになりました。 さらにダッシュボードの統計を改良し、いくつかのバグを修正しました。 diff --git a/fastlane/metadata/ko/release_notes.txt b/fastlane/metadata/ko/release_notes.txt index 41d8100172e..2febc158e4c 100644 --- a/fastlane/metadata/ko/release_notes.txt +++ b/fastlane/metadata/ko/release_notes.txt @@ -1 +1,3 @@ -더 원활한 우커머스 환경을 누리세요! 주문 상세 정보가 온전하게 보이도록 시계 앱이 수정되었습니다. 또한 결제 리더 연결 시 더 명확한 오류 메시지가 추가되었습니다. 업데이트하여 손목과 주머니에서도 자세한 비즈니스 정보를 확인해 보세요. +2주 만에 알찬 릴리스를 또 선보이게 되었습니다. GTIN 전역 제품 식별자가 지원되며 Blaze 캠페인에서 행동 유도 문구를 편집할 수 있습니다. + +대면 결제와 관련된 스토어 설정이 더 빨라지고, 영수증 발송 확인 메시지가 더 명확해지며, iPhone에서의 Tap to Pay 테스트가 더 원활해집니다. 또한 알림판 통계가 개선되었으며 몇 가지 버그가 수정되었습니다. diff --git a/fastlane/metadata/nl-NL/release_notes.txt b/fastlane/metadata/nl-NL/release_notes.txt index db16e1d83ed..582c3c6b9db 100644 --- a/fastlane/metadata/nl-NL/release_notes.txt +++ b/fastlane/metadata/nl-NL/release_notes.txt @@ -1 +1,3 @@ -Profiteer van een soepelere WooCommerce-ervaring! We hebben de Watch-app verbeterd zodat je bestelgegevens vlekkeloos worden weergeven. Bovendien hebben we duidelijkere foutmeldingen toegevoegd voor verbindingen met betalingslezers. Een nog beter inzicht in je bedrijf, direct om je pols en in je zak. +In slechts twee weken hebben we deze release volgepropt. Hij bevat ondersteuning voor wereldwijde GTIN-productidentificatie en je kan de call-to-action in Blaze-campagnes bewerken. + +De winkelconfiguratie voor fysieke betalingen is sneller, bevestigingen van verzonden betalingsbewijzen zijn duidelijker en Tap to Pay testen op iPhone gaat soepeler. Daarnaast hebben we de dashboardstatistieken verbeterd en wat bugs verholpen. diff --git a/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/pt-BR/release_notes.txt index abaf84a9ace..e647ab594be 100644 --- a/fastlane/metadata/pt-BR/release_notes.txt +++ b/fastlane/metadata/pt-BR/release_notes.txt @@ -1 +1,3 @@ -Tenha uma experiência mais fluida no WooCommerce! Ajustamos o app para Apple Watch para mostrar os detalhes do pedido perfeitamente. E mais, adicionamos mensagens de erro mais claras para conexões de leitores de pagamento. Atualize para acompanhar melhor os negócios diretamente do seu pulso e da palma da sua mão. +Em apenas duas semanas, enchemos esta versão de novidades. Agora o identificador de produto global GTIN é compatível, e você pode editar a chamada para ação em campanhas do Blaze. + +A configuração de pagamentos presenciais para lojas ficou mais rápida, as confirmações de envio de recibo mais claras, e os testes do Tap to Pay on iPhone mais simples. Além disso, melhoramos as estatísticas do painel e eliminamos alguns bugs. diff --git a/fastlane/metadata/ru/release_notes.txt b/fastlane/metadata/ru/release_notes.txt index ddf438ce830..fe0ea77e1ae 100644 --- a/fastlane/metadata/ru/release_notes.txt +++ b/fastlane/metadata/ru/release_notes.txt @@ -1 +1,3 @@ -WooCommerce становится всё удобнее. Надеемся, вам понравится! Мы настроили приложение Watch так, чтобы все ваши данные отображались без помех. Кроме того, мы добавили более чёткие сообщения об ошибках при подключении платёжных терминалов. Установите обновления и носите удобный бизнес-центр на руке или в кармане. +Прошло всего две недели, а новый выпуск уже готов. Мы добавили поддержку глобального идентификатора товаров GTIN, а ещё вы теперь можете редактировать призыв к действию в кампаниях Blaze. + +Ускорилась настройка очных платежей в магазине, подтверждение отправки чеков стало понятнее, а тестирование функции Tap to Pay на iPhone прошло без проблем. А кроме того, мы усовершенствовали статистику на консоли и исправили несколько ошибок. diff --git a/fastlane/metadata/sv/release_notes.txt b/fastlane/metadata/sv/release_notes.txt index 592d8615eb3..f0d9d033747 100644 --- a/fastlane/metadata/sv/release_notes.txt +++ b/fastlane/metadata/sv/release_notes.txt @@ -1 +1,3 @@ -Ta del av en smidigare WooCommerce-upplevelse. Vi har finjusterat Watch-appen för felfri visning av din beställningsinformation. Vi har dessutom lagt till tydligare felmeddelanden för betalningsläsaranslutningar. Uppdatera nu för att få bättre koll på din verksamhet, både i klocka och telefon. +På bara två veckor har vi proppat den här utgåvan full. Det finns stöd för GTIN:s globala produktidentifierare, och du kan redigera uppmaningen till handling i Blaze-kampanjer. + +Butiksinställningar för personliga betalningar går fortare, bekräftelser av skickade kvitton är tydligare och det är smidigare att testa ”Tryck för att betala” på iPhone. Dessutom har vi förbättrat adminpanelens statistik och åtgärdat några buggar. diff --git a/fastlane/metadata/tr/release_notes.txt b/fastlane/metadata/tr/release_notes.txt index f7f8d3fcf04..d45967502ff 100644 --- a/fastlane/metadata/tr/release_notes.txt +++ b/fastlane/metadata/tr/release_notes.txt @@ -1 +1,3 @@ -Daha kolay bir WooCommerce deneyimine dalın! Sipariş ayrıntılarınızı kusursuz bir şekilde görüntüleyebilmeniz için Watch uygulamasında ince ayar yaptık. Ayrıca ödeme okuyucu bağlantıları için daha net hata mesajları ekledik. İşlerin nabzınızı bileğinizde ve cebinizde daha iyi şekilde hissetmek için güncelleyin. +Yalnızca iki hafta içinde bu sürümü tamamen doldurduk. GTIN küresel ürün tanımlama desteği sunulur ve Blaze kampanyalarında eylem çağrılarını düzenleyebilirsiniz. + +Şahsen ödeme için mağazada kurulum daha hızlı, fatura gönderilme onayları daha net ve iPhone'da Tap To Pay'in test edilmesi daha kolay. Ayrıca pano istatistikleri iyileştirildi ve bazı hatalar düzeltildi. diff --git a/fastlane/metadata/zh-Hans/release_notes.txt b/fastlane/metadata/zh-Hans/release_notes.txt index 8517c58879d..a69500665e2 100644 --- a/fastlane/metadata/zh-Hans/release_notes.txt +++ b/fastlane/metadata/zh-Hans/release_notes.txt @@ -1 +1,3 @@ -充分感受更顺畅的 WooCommerce 体验! 我们对 Watch 应用程序进行了微调,以便完美展示您的订单详情。 此外,我们还为付款读卡器连接添加了更清晰的错误消息。 敬请更新,以便时刻都能更深入地掌控您的业务。 +在短短两周时间内,我们就成功推出了这一版本。 该版本支持 GTIN 全局产品标识符,并且您还可以在 Blaze 广告活动中编辑号召性用语。 + +门店的现场付款设置更快捷、收据发送确认书更清晰,并且在 iPhone 上测试“点按付款”功能也更流畅。 此外,我们还改进了仪表盘统计数据,并修复了一些错误。 diff --git a/fastlane/metadata/zh-Hant/release_notes.txt b/fastlane/metadata/zh-Hant/release_notes.txt index 9dd8ed245b9..4feeed79a6f 100644 --- a/fastlane/metadata/zh-Hant/release_notes.txt +++ b/fastlane/metadata/zh-Hant/release_notes.txt @@ -1 +1,3 @@ -深入探索更順暢的 WooCommerce 體驗! 我們已微調 Watch 應用程式,讓你可以完美顯示訂單詳細資料。 此外,我們也為付款讀卡機連線新增了更清楚的錯誤訊息。 在智慧手錶或行動裝置更新,進一步掌握業務脈動。 +我們在短短兩週內推出這個版本。 支援 GTIN 全球交易品項識別碼,還可編輯 Blaze 行銷活動的行動呼籲。 + +面對面付款的商店設定變更快、收據傳送確認變更清楚,且 iPhone 卡緊收測試起來更順暢。 此外,我們改善了儀表板統計資料,並修正一些錯誤。