Skip to content

Commit b9c3dbc

Browse files
committed
Merge branch 'trunk' into feat/15144-cash-receipt-style-updates
2 parents 9f6a8a9 + dc6e20b commit b9c3dbc

File tree

33 files changed

+826
-120
lines changed

33 files changed

+826
-120
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- [*] Background image upload: Fix missing error notice in iPhones [https://github.com/woocommerce/woocommerce-ios/pull/15117]
1212
- [*] Background image upload: Show a notice when the user leaves product details while uploads are pending [https://github.com/woocommerce/woocommerce-ios/pull/15134]
1313
- [*] Filters applied in product selector no longer affect the main product list screen. [https://github.com/woocommerce/woocommerce-ios/pull/14764]
14+
- [**] Product Images: Update error handling [https://github.com/woocommerce/woocommerce-ios/pull/15105]
1415

1516
21.7
1617
-----

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,7 @@ enum WooAnalyticsStat: String {
822822
case productImageSettingsAddImagesSourceTapped = "product_image_settings_add_images_source_tapped"
823823
case productImageSettingsDeleteImageButtonTapped = "product_image_settings_delete_image_button_tapped"
824824
case productImageUploadFailed = "product_image_upload_failed"
825+
case productImageUploadRetryButtonTapped = "product_image_upload_retry_button_tapped"
825826
case savingProductAfterBackgroundImageUploadSuccess = "saving_product_after_background_image_upload_success"
826827
case savingProductAfterBackgroundImageUploadFailed = "saving_product_after_background_image_upload_failed"
827828
case failureSavingProductAfterImageUploadNoticeShown = "failure_saving_product_after_image_upload_notice_shown"
Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,48 @@
1+
import protocol WooFoundation.Analytics
12
import Yosemite
23

34
final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTracking {
45
var connectedReaderModel: String?
56

67
private var customerInteractionStarted: Double = 0
8+
private var orderCreated: Double = 0
9+
private var cardReaderReady: Double = 0
10+
private var cardReaderTapped: Double = 0
11+
private var checkoutTapCount: Int = 0
12+
private var hasTrackedProcessingPayment = false
13+
14+
private let analytics: Analytics
15+
16+
init(analytics: Analytics = ServiceLocator.analytics) {
17+
self.analytics = analytics
18+
}
719

820
func preflightResultReceived(_ result: CardReaderPreflightResult?) { }
921
func trackProcessingCompletion(intent: Yosemite.PaymentIntent) { }
1022

1123
func trackSuccessfulPayment(capturedPaymentData: CardPresentCapturedPaymentData) {
12-
let elapsedTime = calculateElapsedTimeInMilliseconds(start: customerInteractionStarted, end: Date().timeIntervalSince1970)
13-
ServiceLocator.analytics.track(event:
14-
.PointOfSale.cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: elapsedTime))
24+
// Property: milliseconds_since_customer_interaction_started
25+
let elapsedTimeSinceCustomerInteraction = calculateElapsedTimeInMilliseconds(since: customerInteractionStarted)
26+
27+
// Property: milliseconds_since_order_creation_success
28+
let elapsedTimeSinceOrderCreation = calculateElapsedTimeInMilliseconds(since: orderCreated)
29+
30+
// Property: milliseconds_since_reader_ready_to_collect_payment
31+
let elapsedTimeSinceCardReaderReady = calculateElapsedTimeInMilliseconds(since: cardReaderReady)
32+
33+
// Property: milliseconds_since_card_tapped
34+
let elapsedTimeSinceCardTapped = calculateElapsedTimeInMilliseconds(since: cardReaderTapped)
35+
36+
analytics.track(event: .PointOfSale.cardPresentCollectPaymentSuccess(
37+
millisecondsSinceCustomerIteractionStarted: elapsedTimeSinceCustomerInteraction,
38+
millisecondsSinceOrderCreationSuccess: elapsedTimeSinceOrderCreation,
39+
millisecondsSinceReaderReadyToCollect: elapsedTimeSinceCardReaderReady,
40+
millisecondsSinceCardTapped: elapsedTimeSinceCardTapped,
41+
checkoutTapCount: checkoutTapCount
42+
))
43+
44+
resetCheckoutTapCountTracker()
45+
resetProcessingPaymentTracking()
1546
}
1647

1748
func trackPaymentFailure(with error: any Error) { }
@@ -23,15 +54,68 @@ final class POSCollectOrderPaymentAnalytics: CollectOrderPaymentAnalyticsTrackin
2354
func trackReceiptPrintFailed(error: any Error) { }
2455

2556
func trackCustomerInteractionStarted() {
57+
// Any action that is considered as user starting an iteraction resets any ongoing counter
58+
resetAllCountersOnInteractionStarted()
2659
customerInteractionStarted = Date().timeIntervalSince1970
2760
}
2861

29-
private func calculateElapsedTimeInMilliseconds(start: Double, end: Double) -> Double {
30-
floor((end - start) * 1000)
62+
func trackOrderCreationSuccess() {
63+
orderCreated = trackCurrentTime()
64+
}
65+
66+
func trackCardReaderReady() {
67+
cardReaderReady = trackCurrentTime()
68+
}
69+
70+
// The Stripe SDK returns multiple `.processing` events, but we want to capture the first one in the stream only.
71+
// This flag is reset as soon as the payment has been successful
72+
func trackCardReaderTapped() {
73+
if !hasTrackedProcessingPayment {
74+
hasTrackedProcessingPayment = true
75+
cardReaderTapped = trackCurrentTime()
76+
}
77+
}
78+
79+
func trackCheckoutTapped() {
80+
checkoutTapCount += 1
81+
}
82+
83+
func resetCheckoutTapCountTracker() {
84+
checkoutTapCount = 0
85+
}
86+
}
87+
88+
// Helpers
89+
private extension POSCollectOrderPaymentAnalytics {
90+
func trackCurrentTime() -> Double {
91+
Date().timeIntervalSince1970
92+
}
93+
94+
func calculateElapsedTimeInMilliseconds(since start: Double) -> Double {
95+
let end = Date().timeIntervalSince1970
96+
return floor((end - start) * 1000)
97+
}
98+
99+
private func resetProcessingPaymentTracking() {
100+
hasTrackedProcessingPayment = false
101+
}
102+
103+
private func resetAllCountersOnInteractionStarted() {
104+
orderCreated = 0
105+
cardReaderReady = 0
106+
cardReaderTapped = 0
107+
resetCheckoutTapCountTracker()
108+
resetProcessingPaymentTracking()
31109
}
32110
}
33111

34112
// Protocol conformance. These events are not needed for IPP, only for POS.
113+
// https://github.com/woocommerce/woocommerce-ios/issues/15149
35114
extension CollectOrderPaymentAnalytics {
36115
func trackCustomerInteractionStarted() { }
116+
func trackOrderCreationSuccess() { }
117+
func trackCardReaderReady() { }
118+
func trackCardReaderTapped() { }
119+
func trackCheckoutTapped() { }
120+
func resetCheckoutTapCountTracker() { }
37121
}

WooCommerce/Classes/POS/Analytics/WooAnalyticsEvent+PointOfSale.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ extension WooAnalyticsEvent {
1313
static let itemType = "product_type"
1414
static let itemsInCart = "items_in_cart"
1515
static let millisecondsSinceCustomerInteractionStarted = "milliseconds_since_customer_interaction_started"
16+
static let millisecondsSinceOrderCreationSuccess = "milliseconds_since_order_creation_success"
17+
static let millisecondsSinceReaderReadyToCollect = "milliseconds_since_reader_ready_to_collect_payment"
18+
static let millisecondsSinceCardTapped = "milliseconds_since_card_tapped"
19+
static let checkoutTapCount = "checkout_tap_count"
1620
}
1721

1822
static func paymentsOnboardingShown() -> WooAnalyticsEvent {
@@ -33,10 +37,18 @@ extension WooAnalyticsEvent {
3337
properties: [Key.itemsInCart: itemsInCart])
3438
}
3539

36-
static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStated: Double) -> WooAnalyticsEvent {
40+
static func cardPresentCollectPaymentSuccess(millisecondsSinceCustomerIteractionStarted: Double,
41+
millisecondsSinceOrderCreationSuccess: Double,
42+
millisecondsSinceReaderReadyToCollect: Double,
43+
millisecondsSinceCardTapped: Double,
44+
checkoutTapCount: Int) -> WooAnalyticsEvent {
3745
WooAnalyticsEvent(statName: .collectPaymentSuccess, properties: [
38-
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStated)"]
39-
)
46+
Key.millisecondsSinceCustomerInteractionStarted: "\(millisecondsSinceCustomerIteractionStarted)",
47+
Key.millisecondsSinceOrderCreationSuccess: "\(millisecondsSinceOrderCreationSuccess)",
48+
Key.millisecondsSinceReaderReadyToCollect: "\(millisecondsSinceReaderReadyToCollect)",
49+
Key.millisecondsSinceCardTapped: "\(millisecondsSinceCardTapped)",
50+
Key.checkoutTapCount: "\(checkoutTapCount)"
51+
])
4052
}
4153
}
4254
}

WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,21 @@ import class WooFoundation.CurrencySettings
1313
import enum WooFoundation.CurrencyCode
1414
import protocol WooFoundation.Analytics
1515

16+
enum SyncOrderState {
17+
case newOrder
18+
case orderUpdated
19+
case orderNotChanged
20+
}
21+
22+
enum SyncOrderStateError: Error {
23+
case syncFailure
24+
}
25+
1626
protocol PointOfSaleOrderControllerProtocol {
1727
var orderState: PointOfSaleInternalOrderState { get }
1828

19-
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async
29+
@discardableResult
30+
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error>
2031
func sendReceipt(recipientEmail: String) async throws
2132
func clearOrder()
2233
func collectCashPayment() async throws
@@ -51,16 +62,15 @@ protocol PointOfSaleOrderControllerProtocol {
5162
private(set) var orderState: PointOfSaleInternalOrderState = .idle
5263
private var order: Order? = nil
5364

54-
@MainActor
65+
@MainActor @discardableResult
5566
func syncOrder(for cartItems: [CartItem],
56-
retryHandler: @escaping () async -> Void) async {
67+
retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
5768
let posCartItems = cartItems.map {
5869
POSCartItem(item: $0.item, quantity: Decimal($0.quantity))
5970
}
6071

61-
guard !orderState.isSyncing,
62-
!posCartItems.matches(order: order) else {
63-
return
72+
guard !orderState.isSyncing, !posCartItems.matches(order: order) else {
73+
return .success(.orderNotChanged)
6474
}
6575

6676
orderState = .syncing
@@ -74,6 +84,9 @@ protocol PointOfSaleOrderControllerProtocol {
7484
orderState = .loaded(totals(for: syncedOrder), syncedOrder)
7585
if isNewOrder {
7686
analytics.track(.orderCreationSuccess)
87+
return .success(.newOrder)
88+
} else {
89+
return .success(.orderUpdated)
7790
}
7891
} catch {
7992
if isNewOrder {
@@ -83,6 +96,7 @@ protocol PointOfSaleOrderControllerProtocol {
8396
errorDescription: error.localizedDescription))
8497
}
8598
setOrderStateToError(error, retryHandler: retryHandler)
99+
return .failure(SyncOrderStateError.syncFailure)
86100
}
87101
}
88102

WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ extension PointOfSaleAggregateModel {
250250

251251
private func cashPaymentSuccess() {
252252
paymentState = .cash(.paymentSuccess)
253+
// TODO: Move to trackSuccessfulCashPayment() on #15151
254+
collectOrderPaymentAnalyticsTracker.resetCheckoutTapCountTracker()
253255
}
254256

255257
@MainActor
@@ -365,6 +367,14 @@ private extension PointOfSaleAggregateModel {
365367
let newPaymentState = PointOfSalePaymentState(from: paymentEvent,
366368
using: presentationStyleDeterminerDependencies)
367369

370+
if case .card(.acceptingCard) = newPaymentState {
371+
collectOrderPaymentAnalyticsTracker.trackCardReaderReady()
372+
}
373+
374+
if case .card(.processingPayment) = newPaymentState {
375+
collectOrderPaymentAnalyticsTracker.trackCardReaderTapped()
376+
}
377+
368378
return newPaymentState
369379
}
370380
.sink(receiveValue: { [weak self] paymentState in
@@ -438,10 +448,14 @@ private extension PointOfSaleAggregateModel {
438448
extension PointOfSaleAggregateModel {
439449
@MainActor
440450
func checkOut() async {
451+
collectOrderPaymentAnalyticsTracker.trackCheckoutTapped()
441452
orderStage = .finalizing
442-
await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
453+
let syncOrderResult = await orderController.syncOrder(for: cart, retryHandler: { [weak self] in
443454
await self?.checkOut()
444455
})
456+
if case .success(.newOrder) = syncOrderResult {
457+
collectOrderPaymentAnalyticsTracker.trackOrderCreationSuccess()
458+
}
445459
await startPaymentWhenCardReaderConnected()
446460
}
447461
}

WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ struct PointOfSaleDashboardView: View {
3333
case .content:
3434
contentView
3535
.accessibilitySortPriority(2)
36-
.ignoresSafeArea(edges: .bottom)
3736
}
3837
} else {
3938
PointOfSaleUnsupportedWidthView()
@@ -45,6 +44,7 @@ struct PointOfSaleDashboardView: View {
4544
showSupport: $showSupport,
4645
showDocumentation: $showDocumentation)
4746
.offset(x: Constants.floatingControlHorizontalOffset, y: -Constants.floatingControlVerticalOffset)
47+
.padding(.bottom, Constants.floatingControlBottomPadding)
4848
.trackSize(size: $floatingSize)
4949
.accessibilitySortPriority(1)
5050
.renderedIf(posModel.itemsViewState.containerState != .loading)
@@ -100,7 +100,6 @@ struct PointOfSaleDashboardView: View {
100100
CartView()
101101
.accessibilitySortPriority(1)
102102
.frame(width: geometry.size.width * Constants.cartWidth)
103-
.ignoresSafeArea(edges: .bottom)
104103
}
105104

106105
if posModel.orderStage == .finalizing {
@@ -168,6 +167,7 @@ private extension PointOfSaleDashboardView {
168167
// For the moment we're just considering landscape for the POS mode
169168
// https://github.com/woocommerce/woocommerce-ios/issues/13251
170169
static let cartWidth: CGFloat = 0.35
170+
static let floatingControlBottomPadding: CGFloat = 16
171171
static let floatingControlHorizontalOffset: CGFloat = 16
172172
static let floatingControlVerticalOffset: CGFloat = 0
173173
static let exitPOSSheetMaxWidth: CGFloat = 900.0

WooCommerce/Classes/POS/Presentation/TotalsView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ private extension TotalsView {
363363
static let pricesIdealWidth: CGFloat = 382
364364
static let verticalSpacing: CGFloat = 56
365365
static let buttonHorizontalPadding: CGFloat = 48
366-
static let cashButtonBottomPadding: CGFloat = 24
366+
static let cashButtonBottomPadding: CGFloat = 16
367367

368368
static let totalsLineViewPadding: EdgeInsets = .init(top: 20, leading: 24, bottom: 20, trailing: 24)
369369
static let subtotalsVerticalSpacing: CGFloat = 8

WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
1212
OrderFactory.newOrder(currency: .USD)
1313
)
1414

15-
func syncOrder(for cartProducts: [CartItem],
16-
retryHandler: @escaping () async -> Void) async { }
15+
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
16+
return .success(.newOrder)
17+
}
1718

1819
func sendReceipt(recipientEmail: String) async throws { }
1920

0 commit comments

Comments
 (0)