Skip to content

Commit

Permalink
Merge pull request #20944 from wordpress-mobile/feature/blaze-campaig…
Browse files Browse the repository at this point in the history
…ns-integrate-api

Blaze Manage Campaigns: Integrate the API
  • Loading branch information
kean authored Jun 29, 2023
2 parents a1cb9d2 + 2a90bdd commit b319b72
Show file tree
Hide file tree
Showing 13 changed files with 352 additions and 52 deletions.
10 changes: 9 additions & 1 deletion WordPress/Classes/Services/BlazeService.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Foundation
import WordPressKit

@objc final class BlazeService: NSObject {
protocol BlazeServiceProtocol {
func getRecentCampaigns(for blog: Blog, completion: @escaping (Result<BlazeCampaignsSearchResponse, Error>) -> Void)
}

@objc final class BlazeService: NSObject, BlazeServiceProtocol {
private let contextManager: CoreDataStackSwift
private let remote: BlazeServiceRemote

Expand All @@ -26,6 +29,10 @@ import WordPressKit

func getRecentCampaigns(for blog: Blog,
completion: @escaping (Result<BlazeCampaignsSearchResponse, Error>) -> Void) {
guard blog.canBlaze else {
completion(.failure(BlazeServiceError.notEligibleForBlaze))
return
}
guard let siteId = blog.dotComID?.intValue else {
DDLogError("Invalid site ID for Blaze")
completion(.failure(BlazeServiceError.missingBlogId))
Expand All @@ -36,5 +43,6 @@ import WordPressKit
}

enum BlazeServiceError: Error {
case notEligibleForBlaze
case missingBlogId
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ private let mockResponse: BlazeCampaignsSearchResponse = {
"start_date": "2023-06-13T00:00:00Z",
"end_date": "2023-06-01T19:15:45Z",
"status": "finished",
"ui_status": "finished",
"avatar_url": "https://0.gravatar.com/avatar/614d27bcc21db12e7c49b516b4750387?s=96&amp;d=identicon&amp;r=G",
"budget_cents": 500,
"target_url": "https://alextest9123.wordpress.com/2023/06/01/test-post/",
Expand All @@ -222,6 +223,7 @@ private let mockResponse: BlazeCampaignsSearchResponse = {
"start_date": "2023-06-13T00:00:00Z",
"end_date": "2023-06-01T19:15:45Z",
"status": "rejected",
"ui_status": "rejected",
"avatar_url": "https://0.gravatar.com/avatar/614d27bcc21db12e7c49b516b4750387?s=96&amp;d=identicon&amp;r=G",
"budget_cents": 5000,
"target_url": "https://alextest9123.wordpress.com/2023/06/01/test-post/",
Expand All @@ -242,6 +244,7 @@ private let mockResponse: BlazeCampaignsSearchResponse = {
"start_date": "2023-06-13T00:00:00Z",
"end_date": "2023-06-01T19:15:45Z",
"status": "active",
"ui_status": "active",
"avatar_url": "https://0.gravatar.com/avatar/614d27bcc21db12e7c49b516b4750387?s=96&amp;d=identicon&amp;r=G",
"budget_cents": 1000,
"target_url": "https://alextest9123.wordpress.com/2023/06/01/test-post/",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class BlazeWebViewModel {
func dismissTapped() {
view.dismissView()
if isFlowCompleted {
NotificationCenter.default.post(name: .blazeCampaignCreated, object: nil)
BlazeEventsTracker.trackBlazeFlowCompleted(for: source, currentStep: currentStep)
} else {
BlazeEventsTracker.trackBlazeFlowCanceled(for: source, currentStep: currentStep)
Expand Down Expand Up @@ -148,6 +149,10 @@ class BlazeWebViewModel {
}
}

extension Foundation.Notification.Name {
static let blazeCampaignCreated = Foundation.Notification.Name("BlazeWebFlowBlazeCampaignCreated")
}

extension BlazeWebViewModel: WebKitAuthenticatable {
var authenticator: RequestAuthenticator? {
RequestAuthenticator(blog: blog)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ struct BlazeCampaignStatusViewModel {
let textColor: UIColor
let backgroundColor: UIColor

init(campaign: BlazeCampaign) {
self.init(status: campaign.uiStatus)
}

init(status: BlazeCampaign.Status) {
self.isHidden = status == .unknown
self.title = status.localizedTitle
Expand Down Expand Up @@ -94,14 +98,17 @@ struct BlazeCampaignStatusViewModel {
extension BlazeCampaign.Status {
var localizedTitle: String {
switch self {
case .created:
// There is no dedicated status for `In Moderation` on the backend.
// The app assumes that the campaign goes into moderation after creation.
return NSLocalizedString("blazeCampaign.status.inmoderation", value: "In Moderation", comment: "Short status description")
case .scheduled:
return NSLocalizedString("blazeCampaign.status.scheduled", value: "Scheduled", comment: "Short status description")
case .created:
return NSLocalizedString("blazeCampaign.status.created", value: "Created", comment: "Short status description")
case .approved:
return NSLocalizedString("blazeCampaign.status.approved", value: "Approved", comment: "Short status description")
case .processing:
return NSLocalizedString("blazeCampaign.status.inmoderation", value: "In Moderation", comment: "Short status description")
// Should never be returned by `ui_status`.
return NSLocalizedString("blazeCampaign.status.processing", value: "Processing", comment: "Short status description")
case .rejected:
return NSLocalizedString("blazeCampaign.status.rejected", value: "Rejected", comment: "Short status description")
case .active:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ final class DashboardBlazeCampaignView: UIView {
}

statsView.isHidden = !viewModel.isShowingStats
if viewModel.isShowingStats {
statsView.arrangedSubviews.forEach { $0.removeFromSuperview() }
if viewModel.isShowingStats {
makeStatsViews(for: viewModel).forEach(statsView.addArrangedSubview)
}
}
Expand Down Expand Up @@ -103,10 +103,10 @@ struct BlazeCampaignViewModel {
let impressions: Int
let clicks: Int
let budget: String
var status: BlazeCampaignStatusViewModel { .init(status: campaign.status) }
var status: BlazeCampaignStatusViewModel { .init(campaign: campaign) }

var isShowingStats: Bool {
switch campaign.status {
switch campaign.uiStatus {
case .created, .processing, .canceled, .approved, .rejected, .scheduled, .unknown:
return false
case .active, .finished:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ final class DashboardBlazeCampaignsCardView: UIView {
BlazeFlowCoordinator.presentBlaze(in: presentingViewController, source: .dashboardCard, blog: blog)
}

func configure(blog: Blog, viewController: BlogDashboardViewController?) {
func configure(blog: Blog, viewController: BlogDashboardViewController?, campaign: BlazeCampaignViewModel) {
self.blog = blog
self.presentingViewController = viewController

Expand All @@ -101,8 +101,7 @@ final class DashboardBlazeCampaignsCardView: UIView {
])
], card: .blaze)

let viewModel = BlazeCampaignViewModel(campaign: mockResponse.campaigns!.first!)
campaignView.configure(with: viewModel, blog: blog)
campaignView.configure(with: campaign, blog: blog)
}
}

Expand All @@ -118,36 +117,3 @@ private extension DashboardBlazeCampaignsCardView {
static let createCampaignInsets = UIEdgeInsets(top: 16, left: 16, bottom: 8, right: 16)
}
}

private let mockResponse: BlazeCampaignsSearchResponse = {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .iso8601
return try! decoder.decode(BlazeCampaignsSearchResponse.self, from: """
{
"totalItems": 3,
"campaigns": [
{
"campaign_id": 26916,
"name": "Test Post - don't approve",
"start_date": "2023-06-13T00:00:00Z",
"end_date": "2023-06-01T19:15:45Z",
"status": "finished",
"avatar_url": "https://0.gravatar.com/avatar/614d27bcc21db12e7c49b516b4750387?s=96&amp;d=identicon&amp;r=G",
"budget_cents": 500,
"target_url": "https://alextest9123.wordpress.com/2023/06/01/test-post/",
"content_config": {
"title": "Test Post - don't approve",
"snippet": "Test Post Empty Empty",
"clickUrl": "https://alextest9123.wordpress.com/2023/06/01/test-post/",
"imageUrl": "https://i0.wp.com/public-api.wordpress.com/wpcom/v2/wordads/dsp/api/v1/dsp/creatives/56259/image?w=600&zoom=2"
},
"campaign_stats": {
"impressions_total": 1000,
"clicks_total": 235
}
}
]
}
""".data(using: .utf8)!)
}()
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
import UIKit
import WordPressKit

final class DashboardBlazeCardCell: DashboardCollectionViewCell {
private var blog: Blog?
private var viewController: BlogDashboardViewController?
private var viewModel: DashboardBlazeCardCellViewModel?

func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) {
self.blog = blog
self.viewController = viewController

BlazeEventsTracker.trackEntryPointDisplayed(for: .dashboardCard)
}

if RemoteFeatureFlag.blazeManageCampaigns.enabled() {
// Display campaigns
let cardView = DashboardBlazeCampaignsCardView()
cardView.configure(blog: blog, viewController: viewController)
setCardView(cardView, subtype: .campaigns)
} else {
// Display promo
func configure(_ viewModel: DashboardBlazeCardCellViewModel) {
guard viewModel !== self.viewModel else { return }
self.viewModel = viewModel

viewModel.onRefresh = { [weak self] in
self?.update(with: $0)
self?.viewController?.collectionView.collectionViewLayout.invalidateLayout()
}
update(with: viewModel)
}

private func update(with viewModel: DashboardBlazeCardCellViewModel) {
guard let blog, let viewController else { return }

switch viewModel.state {
case .promo:
let cardView = DashboardBlazePromoCardView(.make(with: blog, viewController: viewController))
setCardView(cardView, subtype: .promo)
self.setCardView(cardView, subtype: .promo)
case .campaign(let campaign):
let cardView = DashboardBlazeCampaignsCardView()
cardView.configure(blog: blog, viewController: viewController, campaign: campaign)
self.setCardView(cardView, subtype: .campaigns)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation
import WordPressKit

final class DashboardBlazeCardCellViewModel {
private(set) var state: State = .promo

private let blog: Blog
private let service: BlazeServiceProtocol?
private let store: DashboardBlazeStoreProtocol
private var isRefreshing = false
private let isBlazeCampaignsFlagEnabled: () -> Bool

enum State {
/// Showing "Promote you content with Blaze" promo card.
case promo
/// Showing the latest Blaze campaign.
case campaign(BlazeCampaignViewModel)
}

var onRefresh: ((DashboardBlazeCardCellViewModel) -> Void)?

init(blog: Blog,
service: BlazeServiceProtocol? = BlazeService(),
store: DashboardBlazeStoreProtocol = BlogDashboardPersistence(),
isBlazeCampaignsFlagEnabled: @escaping () -> Bool = { RemoteFeatureFlag.blazeManageCampaigns.enabled() }) {
self.blog = blog
self.service = service
self.store = store
self.isBlazeCampaignsFlagEnabled = isBlazeCampaignsFlagEnabled

if isBlazeCampaignsFlagEnabled(),
let blogID = blog.dotComID?.intValue,
let campaign = store.getBlazeCampaign(forBlogID: blogID) {
self.state = .campaign(BlazeCampaignViewModel(campaign: campaign))
}

NotificationCenter.default.addObserver(self, selector: #selector(refresh), name: .blazeCampaignCreated, object: nil)
}

@objc func refresh() {
guard isBlazeCampaignsFlagEnabled() else {
return // Continue showing the default `Promo` card
}

guard !isRefreshing, let service else { return }
isRefreshing = true

service.getRecentCampaigns(for: blog) { [weak self] in
self?.didRefresh(with: $0)
}
}

private func didRefresh(with result: Result<BlazeCampaignsSearchResponse, Error>) {
if case .success(let response) = result {
let campaign = response.campaigns?.first
if let blogID = blog.dotComID?.intValue {
store.setBlazeCampaign(campaign, forBlogID: blogID)
}
if let campaign {
state = .campaign(BlazeCampaignViewModel(campaign: campaign))
} else {
state = .promo
}
}

isRefreshing = false
onRefresh?(self)
}
}

protocol DashboardBlazeStoreProtocol {
func getBlazeCampaign(forBlogID blogID: Int) -> BlazeCampaign?
func setBlazeCampaign(_ campaign: BlazeCampaign?, forBlogID blogID: Int)
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,34 @@ class BlogDashboardPersistence {
"cards_\(blogID).json"
}
}

extension BlogDashboardPersistence: DashboardBlazeStoreProtocol {
func getBlazeCampaign(forBlogID blogID: Int) -> BlazeCampaign? {
do {
let url = try makeBlazeCampaignURL(for: blogID)
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(BlazeCampaign.self, from: data)
} catch {
DDLogError("Failed to retrieve blaze campaign: \(error)")
return nil
}
}

func setBlazeCampaign(_ campaign: BlazeCampaign?, forBlogID blogID: Int) {
do {
let url = try makeBlazeCampaignURL(for: blogID)
if let campaign {
try JSONEncoder().encode(campaign).write(to: url)
} else {
try? FileManager.default.removeItem(at: url)
}
} catch {
DDLogError("Failed to store blaze campaign: \(error)")
}
}

private func makeBlazeCampaignURL(for blogID: Int) throws -> URL {
try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
.appendingPathComponent("recent_blaze_campaign_\(blogID).json", isDirectory: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,24 @@ class BlogDashboardViewModel {
cellConfigurable.row = indexPath.row
cellConfigurable.configure(blog: blog, viewController: viewController, apiResponse: cardModel.apiResponse)
}
(cell as? DashboardBlazeCardCell)?.configure(blazeViewModel)
return cell
case .migrationSuccess:
let cellType = DashboardMigrationSuccessCell.self
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.defaultReuseID, for: indexPath) as? DashboardMigrationSuccessCell
cell?.configure(with: viewController)
return cell
}

}
}()

private let blazeViewModel: DashboardBlazeCardCellViewModel

init(viewController: BlogDashboardViewController, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext, blog: Blog) {
self.viewController = viewController
self.managedObjectContext = managedObjectContext
self.blog = blog
self.blazeViewModel = DashboardBlazeCardCellViewModel(blog: blog)
registerNotifications()
}

Expand All @@ -105,6 +108,8 @@ class BlogDashboardViewModel {

completion?(cards)
})

blazeViewModel.refresh()
}

@objc func loadCardsFromCache() {
Expand Down
Loading

0 comments on commit b319b72

Please sign in to comment.