Skip to content

Commit 16adfbd

Browse files
authored
Merge pull request #5505 from woocommerce/feat/5368-display-jetpack-banner-for-jcp-sites
Jetpack CP: display Jetpack benefits bottom banner for JCP sites and 5 days after the last dismissal
2 parents 50fe961 + 5d82aed commit 16adfbd

File tree

7 files changed

+214
-12
lines changed

7 files changed

+214
-12
lines changed

Storage/Storage/Model/Copiable/Models+Copiable.generated.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ extension GeneralAppSettings {
1212
isSimplePaymentsSwitchEnabled: CopiableProp<Bool> = .copy,
1313
isOrderCreationSwitchEnabled: CopiableProp<Bool> = .copy,
1414
knownCardReaders: CopiableProp<[String]> = .copy,
15-
lastEligibilityErrorInfo: NullableCopiableProp<EligibilityErrorInfo> = .copy
15+
lastEligibilityErrorInfo: NullableCopiableProp<EligibilityErrorInfo> = .copy,
16+
lastJetpackBenefitsBannerDismissedTime: NullableCopiableProp<Date> = .copy
1617
) -> GeneralAppSettings {
1718
let installationDate = installationDate ?? self.installationDate
1819
let feedbacks = feedbacks ?? self.feedbacks
@@ -21,6 +22,7 @@ extension GeneralAppSettings {
2122
let isOrderCreationSwitchEnabled = isOrderCreationSwitchEnabled ?? self.isOrderCreationSwitchEnabled
2223
let knownCardReaders = knownCardReaders ?? self.knownCardReaders
2324
let lastEligibilityErrorInfo = lastEligibilityErrorInfo ?? self.lastEligibilityErrorInfo
25+
let lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime ?? self.lastJetpackBenefitsBannerDismissedTime
2426

2527
return GeneralAppSettings(
2628
installationDate: installationDate,
@@ -29,7 +31,8 @@ extension GeneralAppSettings {
2931
isSimplePaymentsSwitchEnabled: isSimplePaymentsSwitchEnabled,
3032
isOrderCreationSwitchEnabled: isOrderCreationSwitchEnabled,
3133
knownCardReaders: knownCardReaders,
32-
lastEligibilityErrorInfo: lastEligibilityErrorInfo
34+
lastEligibilityErrorInfo: lastEligibilityErrorInfo,
35+
lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime
3336
)
3437
}
3538
}

Storage/Storage/Model/GeneralAppSettings.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,25 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable {
4141
///
4242
public let lastEligibilityErrorInfo: EligibilityErrorInfo?
4343

44+
/// The last time the Jetpack benefits banner is dismissed.
45+
public let lastJetpackBenefitsBannerDismissedTime: Date?
46+
4447
public init(installationDate: Date?,
4548
feedbacks: [FeedbackType: FeedbackSettings],
4649
isViewAddOnsSwitchEnabled: Bool,
4750
isSimplePaymentsSwitchEnabled: Bool,
4851
isOrderCreationSwitchEnabled: Bool,
4952
knownCardReaders: [String],
50-
lastEligibilityErrorInfo: EligibilityErrorInfo? = nil) {
53+
lastEligibilityErrorInfo: EligibilityErrorInfo? = nil,
54+
lastJetpackBenefitsBannerDismissedTime: Date? = nil) {
5155
self.installationDate = installationDate
5256
self.feedbacks = feedbacks
5357
self.isViewAddOnsSwitchEnabled = isViewAddOnsSwitchEnabled
5458
self.isSimplePaymentsSwitchEnabled = isSimplePaymentsSwitchEnabled
5559
self.isOrderCreationSwitchEnabled = isOrderCreationSwitchEnabled
5660
self.knownCardReaders = knownCardReaders
5761
self.lastEligibilityErrorInfo = lastEligibilityErrorInfo
62+
self.lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime
5863
}
5964

6065
/// Returns the status of a given feedback type. If the feedback is not stored in the feedback array. it is assumed that it has a pending status.
@@ -100,6 +105,7 @@ extension GeneralAppSettings {
100105
self.isOrderCreationSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isOrderCreationSwitchEnabled) ?? false
101106
self.knownCardReaders = try container.decodeIfPresent([String].self, forKey: .knownCardReaders) ?? []
102107
self.lastEligibilityErrorInfo = try container.decodeIfPresent(EligibilityErrorInfo.self, forKey: .lastEligibilityErrorInfo)
108+
self.lastJetpackBenefitsBannerDismissedTime = try container.decodeIfPresent(Date.self, forKey: .lastJetpackBenefitsBannerDismissedTime)
103109

104110
// Decode new properties with `decodeIfPresent` and provide a default value if necessary.
105111
}

Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import XCTest
22
@testable import Storage
33

4-
class GeneralAppSettingsTests: XCTestCase {
4+
final class GeneralAppSettingsTests: XCTestCase {
55

66
func test_it_returns_the_correct_status_of_a_stored_feedback() {
77
// Given

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class DashboardViewController: UIViewController {
1515
private let siteID: Int64
1616

1717
private let dashboardUIFactory: DashboardUIFactory
18-
private var dashboardUI: DashboardUI?
18+
@Published private var dashboardUI: DashboardUI?
1919

2020
// Used to enable subtitle with store name
2121
private var shouldShowStoreNameAsSubtitle: Bool = false
@@ -110,6 +110,7 @@ final class DashboardViewController: UIViewController {
110110
configureDashboardUIContainer()
111111
configureBottomJetpackBenefitsBanner()
112112
observeSiteForUIUpdates()
113+
observeBottomJetpackBenefitsBannerVisibilityUpdates()
113114
}
114115

115116
override func viewWillAppear(_ animated: Bool) {
@@ -239,7 +240,9 @@ private extension DashboardViewController {
239240
}
240241
self.present(benefitsController, animated: true, completion: nil)
241242
} dismissAction: { [weak self] in
242-
// TODO: 5362 - Persist dismiss state per site
243+
let dismissAction = AppSettingsAction.setJetpackBenefitsBannerLastDismissedTime(time: Date())
244+
ServiceLocator.stores.dispatch(dismissAction)
245+
243246
self?.hideJetpackBenefitsBanner()
244247
}
245248
}
@@ -323,25 +326,25 @@ private extension DashboardViewController {
323326
remove(previousDashboardUI)
324327
}
325328

326-
dashboardUI = updatedDashboardUI
327-
328329
let contentView = updatedDashboardUI.view!
329330
addChild(updatedDashboardUI)
330331
containerView.addSubview(contentView)
331332
updatedDashboardUI.didMove(toParent: self)
332333
addViewBelowHeaderStackView(contentView: contentView)
333334

335+
// Sets `dashboardUI` after its view is added to the view hierarchy so that observers can update UI based on its view.
336+
dashboardUI = updatedDashboardUI
337+
334338
updatedDashboardUI.onPullToRefresh = { [weak self] in
335339
self?.pullToRefresh()
336340
}
337341
updatedDashboardUI.displaySyncingError = { [weak self] in
338342
self?.showTopBannerView()
339343
}
344+
}
340345

341-
// Bottom banner
342-
// TODO: 5362 & 5368 - Display banner for JCP sites and if the banner has not been dismissed before.
343-
let shouldShowJetpackBenefitsBanner = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.jetpackConnectionPackageSupport)
344-
if shouldShowJetpackBenefitsBanner {
346+
func updateJetpackBenefitsBannerVisibility(isBannerVisible: Bool, contentView: UIView) {
347+
if isBannerVisible {
345348
showJetpackBenefitsBanner(contentView: contentView)
346349
} else {
347350
hideJetpackBenefitsBanner()
@@ -426,6 +429,30 @@ private extension DashboardViewController {
426429
self.reloadData(forced: true)
427430
}.store(in: &cancellables)
428431
}
432+
433+
func observeBottomJetpackBenefitsBannerVisibilityUpdates() {
434+
Publishers.CombineLatest(ServiceLocator.stores.site, $dashboardUI.eraseToAnyPublisher())
435+
.sink { [weak self] site, dashboardUI in
436+
guard let self = self else { return }
437+
438+
guard let contentView = dashboardUI?.view else {
439+
return
440+
}
441+
442+
// Checks if Jetpack banner can be visible from app settings.
443+
let action = AppSettingsAction.loadJetpackBenefitsBannerVisibility(currentTime: Date(),
444+
calendar: .current) { [weak self] isVisibleFromAppSettings in
445+
guard let self = self else { return }
446+
447+
let shouldShowJetpackBenefitsBanner = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.jetpackConnectionPackageSupport)
448+
&& site?.isJetpackCPConnected == true
449+
&& isVisibleFromAppSettings
450+
451+
self.updateJetpackBenefitsBannerVisibility(isBannerVisible: shouldShowJetpackBenefitsBanner, contentView: contentView)
452+
}
453+
ServiceLocator.stores.dispatch(action)
454+
}.store(in: &cancellables)
455+
}
429456
}
430457

431458
// MARK: Constants

Yosemite/Yosemite/Actions/AppSettingsAction.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ public enum AppSettingsAction: Action {
163163
///
164164
case resetEligibilityErrorInfo
165165

166+
/// Sets the last time when Jetpack benefits banner is dismissed in the Dashboard.
167+
///
168+
case setJetpackBenefitsBannerLastDismissedTime(time: Date)
169+
170+
/// Loads the visibility of Jetpack benefits banner in the Dashboard based on the last dismissal time.
171+
/// The banner is not shown for five days after the last time it is dismissed.
172+
/// There are other conditions for showing the Jetpack banner, like when the site is Jetpack CP connected.
173+
///
174+
case loadJetpackBenefitsBannerVisibility(currentTime: Date, calendar: Calendar, onCompletion: (Bool) -> Void)
175+
166176
// MARK: - General Store Settings
167177

168178
/// Sets telemetry availability status information.

Yosemite/Yosemite/Stores/AppSettingsStore.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ public class AppSettingsStore: Store {
181181
setEligibilityErrorInfo(errorInfo: errorInfo, onCompletion: onCompletion)
182182
case .resetEligibilityErrorInfo:
183183
setEligibilityErrorInfo(errorInfo: nil)
184+
case .setJetpackBenefitsBannerLastDismissedTime(time: let time):
185+
setJetpackBenefitsBannerLastDismissedTime(time: time)
186+
case .loadJetpackBenefitsBannerVisibility(currentTime: let currentTime, calendar: let calendar, onCompletion: let onCompletion):
187+
loadJetpackBenefitsBannerVisibility(currentTime: currentTime, calendar: calendar, onCompletion: onCompletion)
184188
case .setTelemetryAvailability(siteID: let siteID, isAvailable: let isAvailable):
185189
setTelemetryAvailability(siteID: siteID, isAvailable: isAvailable)
186190
case .setTelemetryLastReportedTime(siteID: let siteID, time: let time):
@@ -326,6 +330,34 @@ private extension AppSettingsStore {
326330
}
327331
}
328332

333+
// Visibility of Jetpack benefits banner in the Dashboard
334+
335+
func setJetpackBenefitsBannerLastDismissedTime(time: Date, onCompletion: ((Result<Void, Error>) -> Void)? = nil) {
336+
do {
337+
let settings = loadOrCreateGeneralAppSettings().copy(lastJetpackBenefitsBannerDismissedTime: time)
338+
try saveGeneralAppSettings(settings)
339+
onCompletion?(.success(()))
340+
} catch {
341+
onCompletion?(.failure(error))
342+
}
343+
}
344+
345+
func loadJetpackBenefitsBannerVisibility(currentTime: Date, calendar: Calendar, onCompletion: (Bool) -> Void) {
346+
let settings = loadOrCreateGeneralAppSettings()
347+
348+
guard let lastDismissedTime = settings.lastJetpackBenefitsBannerDismissedTime else {
349+
// If the banner has not been dismissed before, the banner is default to be visible.
350+
return onCompletion(true)
351+
}
352+
353+
guard let numberOfDaysSinceLastDismissal = calendar.dateComponents([.day], from: lastDismissedTime, to: currentTime).day else {
354+
return onCompletion(true)
355+
}
356+
onCompletion(numberOfDaysSinceLastDismissal >= 5)
357+
}
358+
359+
// File operations
360+
329361
/// Load the `GeneralAppSettings` from file or create an empty one if it doesn't exist.
330362
func loadOrCreateGeneralAppSettings() -> GeneralAppSettings {
331363
guard let settings: GeneralAppSettings = try? fileStorage.data(for: generalAppSettingsFileURL) else {

Yosemite/YosemiteTests/Stores/AppSettingsStoreTests.swift

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,130 @@ final class AppSettingsStoreTests: XCTestCase {
519519
XCTAssertTrue(isEnabled)
520520
}
521521

522+
func test_loadJetpackBenefitsBannerVisibility_returns_true_on_new_generalAppSettings() throws {
523+
// Given
524+
// Deletes any pre-existing app settings.
525+
try fileStorage?.deleteFile(at: expectedGeneralAppSettingsFileURL)
526+
527+
// GMT - Sunday, November 28, 2021 1:25:24 PM
528+
let currentTime = Date(timeIntervalSince1970: 1638105924)
529+
let calendar = Calendar(identifier: .gregorian)
530+
531+
// When
532+
let isVisible: Bool = waitFor { promise in
533+
let action = AppSettingsAction.loadJetpackBenefitsBannerVisibility(currentTime: currentTime, calendar: calendar) { isVisible in
534+
promise(isVisible)
535+
}
536+
self.subject?.onAction(action)
537+
}
538+
539+
// Then
540+
// The banner is visible if there are no pre-existing app settings.
541+
XCTAssertTrue(isVisible)
542+
}
543+
544+
func test_loadJetpackBenefitsBannerVisibility_returns_true_after_setting_last_dismissed_date_exactly_five_days_ago_without_dst() throws {
545+
// Given
546+
try fileStorage?.deleteFile(at: expectedGeneralAppSettingsFileURL)
547+
548+
// GMT - Tuesday, November 23, 2021 1:25:24 PM
549+
let lastDismissedTime = Date(timeIntervalSince1970: 1637673924)
550+
// GMT - Sunday, November 28, 2021 1:25:24 PM - exactly five days after the last dismissed date without DST
551+
let currentTime = Date(timeIntervalSince1970: 1638105924)
552+
let calendar: Calendar = {
553+
var calendar = Calendar(identifier: .gregorian)
554+
guard let timeZoneWithoutDaylightSavingTime = TimeZone(identifier: "Asia/Taipei") else {
555+
XCTFail("Unexpected time zone.")
556+
return calendar
557+
}
558+
calendar.timeZone = timeZoneWithoutDaylightSavingTime
559+
return calendar
560+
}()
561+
562+
let updateAction = AppSettingsAction.setJetpackBenefitsBannerLastDismissedTime(time: lastDismissedTime)
563+
subject?.onAction(updateAction)
564+
565+
// When
566+
let isVisible: Bool = waitFor { promise in
567+
let action = AppSettingsAction.loadJetpackBenefitsBannerVisibility(currentTime: currentTime, calendar: calendar) { isVisible in
568+
promise(isVisible)
569+
}
570+
self.subject?.onAction(action)
571+
}
572+
573+
// Then
574+
XCTAssertTrue(isVisible)
575+
}
576+
577+
/// Tests an edge case where the time interval since the last dismissed date is less than 5 24-hour days, but is exactly 5 days on calendar with daylight
578+
/// saving time.
579+
func test_loadJetpackBenefitsBannerVisibility_returns_false_after_setting_last_dismissed_date_exactly_five_24hr_days_ago() throws {
580+
// Given
581+
try fileStorage?.deleteFile(at: expectedGeneralAppSettingsFileURL)
582+
583+
// America/New York (EDT) - November 03, 2021 09:43:17 AM
584+
let lastDismissedTime = Date(timeIntervalSince1970: 1635946997)
585+
// America/New York (EST) - November 08, 2021 08:43:17 AM - exactly five 24-hour days after the last dismissed date.
586+
// But with daylight saving time in America/New York, it is still less than five days.
587+
let currentTime = Date(timeIntervalSince1970: 1636378997)
588+
let calendar: Calendar = {
589+
var calendar = Calendar(identifier: .gregorian)
590+
guard let timeZoneWithDaylightSavingTime = TimeZone(identifier: "America/New_York") else {
591+
XCTFail("Unexpected time zone.")
592+
return calendar
593+
}
594+
calendar.timeZone = timeZoneWithDaylightSavingTime
595+
return calendar
596+
}()
597+
598+
let updateAction = AppSettingsAction.setJetpackBenefitsBannerLastDismissedTime(time: lastDismissedTime)
599+
subject?.onAction(updateAction)
600+
601+
// When
602+
let isVisible: Bool = waitFor { promise in
603+
let action = AppSettingsAction.loadJetpackBenefitsBannerVisibility(currentTime: currentTime, calendar: calendar) { isVisible in
604+
promise(isVisible)
605+
}
606+
self.subject?.onAction(action)
607+
}
608+
609+
// Then
610+
XCTAssertFalse(isVisible)
611+
}
612+
613+
func test_loadJetpackBenefitsBannerVisibility_returns_false_after_setting_last_dismissed_date_less_than_five_days_ago() throws {
614+
// Given
615+
try fileStorage?.deleteFile(at: expectedGeneralAppSettingsFileURL)
616+
617+
// GMT - Tuesday, November 23, 2021 1:25:24 PM
618+
let lastDismissedTime = Date(timeIntervalSince1970: 1637673924)
619+
// GMT - Sunday, November 28, 2021 1:25:23 PM - exactly 1 second less than five days after the last dismissed date without DST
620+
let currentTime = Date(timeIntervalSince1970: 1638105923)
621+
let calendar: Calendar = {
622+
var calendar = Calendar(identifier: .gregorian)
623+
guard let timeZoneWithoutDaylightSavingTime = TimeZone(identifier: "Asia/Taipei") else {
624+
XCTFail("Unexpected time zone.")
625+
return calendar
626+
}
627+
calendar.timeZone = timeZoneWithoutDaylightSavingTime
628+
return calendar
629+
}()
630+
631+
let updateAction = AppSettingsAction.setJetpackBenefitsBannerLastDismissedTime(time: lastDismissedTime)
632+
subject?.onAction(updateAction)
633+
634+
// When
635+
let isVisible: Bool = waitFor { promise in
636+
let action = AppSettingsAction.loadJetpackBenefitsBannerVisibility(currentTime: currentTime, calendar: calendar) { isVisible in
637+
promise(isVisible)
638+
}
639+
self.subject?.onAction(action)
640+
}
641+
642+
// Then
643+
XCTAssertFalse(isVisible)
644+
}
645+
522646
// MARK: - General Store Settings
523647

524648
func test_saving_isTelemetryAvailable_works_correctly() throws {

0 commit comments

Comments
 (0)