Skip to content

Commit ac2d420

Browse files
authored
Merge pull request #7052 from woocommerce/issue/6492-create-coupon
Coupons: Create coupon
2 parents 85d3ce6 + 7fc54f9 commit ac2d420

File tree

10 files changed

+309
-68
lines changed

10 files changed

+309
-68
lines changed

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ public enum WooAnalyticsStat: String {
578578
case couponsLoaded = "coupons_loaded"
579579
case couponsLoadedFailed = "coupons_loaded_failed"
580580
case couponsListSearchTapped = "coupons_list_search_tapped"
581+
case couponsListCreateTapped = "coupons_list_create_tapped"
581582
case couponDetails = "coupon_details"
582583
case couponSettingDisabled = "coupon_settings_disabled"
583584
case couponSettingEnabled = "coupon_settings_enabled"
@@ -586,6 +587,9 @@ public enum WooAnalyticsStat: String {
586587
case couponUpdateInitiated = "coupon_update_initiated"
587588
case couponUpdateSuccess = "coupon_update_success"
588589
case couponUpdateFailed = "coupon_update_failed"
590+
case couponCreationInitiated = "coupon_creation_initiated"
591+
case couponCreationSuccess = "coupon_creation_success"
592+
case couponCreationFailed = "coupon_creation_failed"
589593

590594
// MARK: Inbox Notes
591595
case inboxNotesLoaded = "inbox_notes_loaded"

WooCommerce/Classes/Model/Coupon+Woo.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ extension Coupon {
127127
return amountString
128128
}
129129

130+
/// The message to be shared about the coupon
131+
///
132+
func generateShareMessage(currencySettings: CurrencySettings) -> String {
133+
let formattedAmount = formattedAmount(currencySettings: currencySettings)
134+
let couponAmount = formattedAmount.isEmpty ? amount : formattedAmount
135+
if productIds.isNotEmpty ||
136+
productCategories.isNotEmpty ||
137+
excludedProductIds.isNotEmpty ||
138+
excludedProductCategories.isNotEmpty {
139+
return String.localizedStringWithFormat(Localization.shareMessageSomeProducts, couponAmount, code)
140+
}
141+
return String.localizedStringWithFormat(Localization.shareMessageAllProducts, couponAmount, code)
142+
}
143+
130144
/// Localize content for the "Apply to" field. This takes into consideration different cases of apply rules:
131145
/// - When only specific products or categories are defined: Display "x Products" or "x Categories"
132146
/// - When specific products/categories and exceptions are defined: Display "x Products excl. y Categories" etc.
@@ -246,6 +260,14 @@ extension Coupon {
246260
"%1$@, %2$@",
247261
comment: "Combined rule for a coupon. Reads like: 2 Products, 1 Category"
248262
)
263+
static let shareMessageAllProducts = NSLocalizedString(
264+
"Apply %1$@ off to all products with the promo code “%2$@”.",
265+
comment: "Message to share the coupon code if it is applicable to all products. " +
266+
"Reads like: Apply 10% off to all products with the promo code “20OFF”.")
267+
static let shareMessageSomeProducts = NSLocalizedString(
268+
"Apply %1$@ off to some products with the promo code “%2$@”.",
269+
comment: "Message to share the coupon code if it is applicable to some products. " +
270+
"Reads like: Apply 10% off to some products with the promo code “20OFF”.")
249271
}
250272
}
251273

WooCommerce/Classes/ViewRelated/Coupons/Add and Edit Coupons/AddEditCoupon.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,11 @@ struct AddEditCoupon: View {
256256
Button {
257257
// This should be replaced with `@FocusState` when we drop support for iOS 14
258258
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
259-
viewModel.updateCoupon(coupon: viewModel.populatedCoupon, onUpdateFinished: {
259+
viewModel.completeCouponAddEdit(coupon: viewModel.populatedCoupon, onUpdateFinished: {
260260
dismissHandler()
261261
})
262262
} label: {
263-
Text(Localization.saveButton)
263+
Text(viewModel.addEditCouponButtonText)
264264
}
265265
.buttonStyle(PrimaryLoadingButtonStyle(isLoading: viewModel.isLoading))
266266
.padding(.horizontal, Constants.margin)
@@ -304,6 +304,15 @@ struct AddEditCoupon: View {
304304
categoryListConfig: categoryListConfig,
305305
viewModel: viewModel.categorySelectorViewModel)
306306
}
307+
.sheet(isPresented: $viewModel.showingCouponCreationSuccess) {
308+
let couponCode = viewModel.coupon?.code ?? ""
309+
if couponCode.isEmpty {
310+
let _ = DDLogError("⛔️ Error acquiring the coupon code after creation")
311+
}
312+
CouponCreationSuccess(couponCode: couponCode, shareMessage: viewModel.shareCouponMessage) {
313+
onDisappear()
314+
}
315+
}
307316
.toolbar {
308317
ToolbarItem(placement: .cancellationAction) {
309318
Button(Localization.cancelButton, action: {
@@ -377,7 +386,6 @@ private extension AddEditCoupon {
377386
static let usageRestrictions = NSLocalizedString(
378387
"Usage Restrictions",
379388
comment: "Field in the view for adding or editing a coupon.")
380-
static let saveButton = NSLocalizedString("Save", comment: "Action for saving a Coupon remotely")
381389
static let addDescriptionPlaceholder = NSLocalizedString("Add the description of the coupon.",
382390
comment: "Placeholder text that will be shown in the view" +
383391
" for adding the description of a coupon.")
@@ -396,7 +404,7 @@ struct AddEditCoupon_Previews: PreviewProvider {
396404

397405
/// Edit Coupon
398406
///
399-
let editingViewModel = AddEditCouponViewModel(existingCoupon: Coupon.sampleCoupon, onCompletion: { _ in })
407+
let editingViewModel = AddEditCouponViewModel(existingCoupon: Coupon.sampleCoupon, onSuccess: { _ in })
400408
AddEditCoupon(editingViewModel)
401409
}
402410
}

WooCommerce/Classes/ViewRelated/Coupons/Add and Edit Coupons/AddEditCouponViewModel.swift

Lines changed: 97 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import Yosemite
33
import UIKit
4+
import WooFoundation
45
import protocol Storage.StorageManagerType
56

67
/// View model for `AddEditCoupon` view
@@ -13,7 +14,7 @@ final class AddEditCouponViewModel: ObservableObject {
1314
///
1415
private let editingOption: EditingOption
1516

16-
private let onCompletion: ((Result<Coupon, Error>) -> Void)
17+
private let onSuccess: (Coupon) -> Void
1718

1819
/// Defines the current notice that should be shown.
1920
/// Defaults to `nil`.
@@ -29,6 +30,18 @@ final class AddEditCouponViewModel: ObservableObject {
2930
}
3031
}
3132

33+
/// Defines the main action button text that should be shown.
34+
/// Switching between `Create` or `Save` action.
35+
///
36+
var addEditCouponButtonText: String {
37+
switch editingOption {
38+
case .creation:
39+
return Localization.createButton
40+
case .editing:
41+
return Localization.saveButton
42+
}
43+
}
44+
3245
/// The value for populating the coupon discount type field based on the `discountType`.
3346
///
3447
var discountTypeValue: TitleAndValueRow.Value {
@@ -127,7 +140,12 @@ final class AddEditCouponViewModel: ObservableObject {
127140
categoryIDs.isNotEmpty
128141
}
129142

143+
var shareCouponMessage: String {
144+
coupon?.generateShareMessage(currencySettings: currencySettings) ?? ""
145+
}
146+
130147
var hasChangesMade: Bool {
148+
guard editingOption == .editing else { return true }
131149
let coupon = populatedCoupon
132150
return checkDiscountTypeUpdated(for: coupon) ||
133151
checkAmountUpdated(for: coupon) ||
@@ -142,6 +160,7 @@ final class AddEditCouponViewModel: ObservableObject {
142160
private(set) var coupon: Coupon?
143161
private let stores: StoresManager
144162
private let storageManager: StorageManagerType
163+
private let currencySettings: CurrencySettings
145164
let timezone: TimeZone
146165

147166
/// When the view is updating or creating a new Coupon remotely.
@@ -162,22 +181,25 @@ final class AddEditCouponViewModel: ObservableObject {
162181
@Published var couponRestrictionsViewModel: CouponRestrictionsViewModel
163182
@Published var productOrVariationIDs: [Int64]
164183
@Published var categoryIDs: [Int64]
184+
@Published var showingCouponCreationSuccess: Bool = false
165185

166186
/// Init method for coupon creation
167187
///
168188
init(siteID: Int64,
169189
discountType: Coupon.DiscountType,
170190
stores: StoresManager = ServiceLocator.stores,
171191
storageManager: StorageManagerType = ServiceLocator.storageManager,
192+
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
172193
timezone: TimeZone = .siteTimezone,
173-
onCompletion: @escaping ((Result<Coupon, Error>) -> Void)) {
194+
onSuccess: @escaping (Coupon) -> Void) {
174195
self.siteID = siteID
175196
editingOption = .creation
176197
self.discountType = discountType
177198
self.stores = stores
178199
self.storageManager = storageManager
200+
self.currencySettings = currencySettings
179201
self.timezone = timezone
180-
self.onCompletion = onCompletion
202+
self.onSuccess = onSuccess
181203

182204
amountField = String()
183205
codeField = String()
@@ -187,23 +209,26 @@ final class AddEditCouponViewModel: ObservableObject {
187209
couponRestrictionsViewModel = CouponRestrictionsViewModel(siteID: siteID)
188210
productOrVariationIDs = []
189211
categoryIDs = []
212+
generateRandomCouponCode()
190213
}
191214

192215
/// Init method for coupon editing
193216
///
194217
init(existingCoupon: Coupon,
195218
stores: StoresManager = ServiceLocator.stores,
196219
storageManager: StorageManagerType = ServiceLocator.storageManager,
220+
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
197221
timezone: TimeZone = .siteTimezone,
198-
onCompletion: @escaping ((Result<Coupon, Error>) -> Void)) {
222+
onSuccess: @escaping (Coupon) -> Void) {
199223
siteID = existingCoupon.siteID
200224
coupon = existingCoupon
201225
editingOption = .editing
202226
discountType = existingCoupon.discountType
203227
self.stores = stores
204228
self.storageManager = storageManager
229+
self.currencySettings = currencySettings
205230
self.timezone = timezone
206-
self.onCompletion = onCompletion
231+
self.onSuccess = onSuccess
207232

208233
// Populate fields
209234
amountField = existingCoupon.amount
@@ -233,13 +258,50 @@ final class AddEditCouponViewModel: ObservableObject {
233258
codeField = code
234259
}
235260

236-
func updateCoupon(coupon: Coupon, onUpdateFinished: @escaping () -> Void) {
261+
func completeCouponAddEdit(coupon: Coupon, onUpdateFinished: @escaping () -> Void) {
262+
switch editingOption {
263+
case .creation:
264+
createCoupon(coupon: coupon)
265+
case .editing:
266+
updateCoupon(coupon: coupon, onUpdateFinished: onUpdateFinished)
267+
}
268+
}
269+
270+
private func createCoupon(coupon: Coupon) {
271+
trackCouponCreateInitiated(with: coupon)
272+
273+
if let validationError = validateCouponLocally(coupon) {
274+
notice = NoticeFactory.createCouponErrorNotice(validationError,
275+
editingOption: editingOption)
276+
return
277+
}
278+
279+
isLoading = true
280+
let action = CouponAction.createCoupon(coupon, siteTimezone: timezone) { [weak self] result in
281+
guard let self = self else { return }
282+
self.isLoading = false
283+
switch result {
284+
case .success(let coupon):
285+
ServiceLocator.analytics.track(.couponCreationSuccess)
286+
self.coupon = coupon
287+
self.onSuccess(coupon)
288+
self.showingCouponCreationSuccess = true
289+
case .failure(let error):
290+
DDLogError("⛔️ Error creating the coupon: \(error)")
291+
ServiceLocator.analytics.track(.couponCreationFailed, withError: error)
292+
self.notice = NoticeFactory.createCouponErrorNotice(.other(error: error),
293+
editingOption: self.editingOption)
294+
}
295+
}
296+
stores.dispatch(action)
297+
}
298+
299+
private func updateCoupon(coupon: Coupon, onUpdateFinished: @escaping () -> Void) {
237300
trackCouponUpdateInitiated(with: coupon)
238301

239302
if let validationError = validateCouponLocally(coupon) {
240303
notice = NoticeFactory.createCouponErrorNotice(validationError,
241304
editingOption: editingOption)
242-
onCompletion(.failure(validationError))
243305
return
244306
}
245307

@@ -248,9 +310,9 @@ final class AddEditCouponViewModel: ObservableObject {
248310
guard let self = self else { return }
249311
self.isLoading = false
250312
switch result {
251-
case .success(_):
313+
case .success(let updatedCoupon):
252314
ServiceLocator.analytics.track(.couponUpdateSuccess)
253-
self.onCompletion(result)
315+
self.onSuccess(updatedCoupon)
254316
onUpdateFinished()
255317
case .failure(let error):
256318
DDLogError("⛔️ Error updating the coupon: \(error)")
@@ -329,6 +391,19 @@ private extension AddEditCouponViewModel {
329391
return coupon.discountType != initialCoupon.discountType
330392
}
331393

394+
func checkUsageRestrictionsOnCreation(of coupon: Coupon) -> Bool {
395+
coupon.maximumAmount.isNotEmpty ||
396+
coupon.minimumAmount.isNotEmpty ||
397+
(coupon.usageLimit ?? 0) > 0 ||
398+
(coupon.usageLimitPerUser ?? 0) > 0 ||
399+
(coupon.limitUsageToXItems ?? 0) > 0 ||
400+
coupon.emailRestrictions.isNotEmpty ||
401+
coupon.individualUse ||
402+
coupon.excludeSaleItems ||
403+
coupon.excludedProductIds.isNotEmpty ||
404+
coupon.excludedProductCategories.isNotEmpty
405+
}
406+
332407
func checkUsageRestrictionsUpdated(for coupon: Coupon) -> Bool {
333408
guard let initialCoupon = self.coupon else {
334409
return false
@@ -396,6 +471,17 @@ private extension AddEditCouponViewModel {
396471
return coupon.freeShipping != initialCoupon.freeShipping
397472
}
398473

474+
func trackCouponCreateInitiated(with coupon: Coupon) {
475+
ServiceLocator.analytics.track(.couponCreationInitiated, withProperties: [
476+
"discount_type": coupon.discountType.rawValue,
477+
"has_expiry_date": coupon.dateExpires != nil,
478+
"includes_free_shipping": coupon.freeShipping,
479+
"has_description": coupon.description.isNotEmpty,
480+
"has_product_or_category_restrictions": coupon.excludedProductCategories.isNotEmpty || coupon.excludedProductIds.isNotEmpty,
481+
"has_usage_restrictions": checkUsageRestrictionsOnCreation(of: coupon)
482+
])
483+
}
484+
399485
func trackCouponUpdateInitiated(with coupon: Coupon) {
400486
ServiceLocator.analytics.track(.couponUpdateInitiated, withProperties: [
401487
"discount_type_updated": checkDiscountTypeUpdated(for: coupon),
@@ -474,5 +560,7 @@ private extension AddEditCouponViewModel {
474560
"Reads like: Edit Categories")
475561
static let createCouponTitle = NSLocalizedString("Create coupon", comment: "Title of the Create coupon screen")
476562
static let editCouponTitle = NSLocalizedString("Edit coupon", comment: "Title of the Edit coupon screen")
563+
static let saveButton = NSLocalizedString("Save", comment: "Action for saving a Coupon remotely")
564+
static let createButton = NSLocalizedString("Create", comment: "Action for creating a Coupon remotely")
477565
}
478566
}

WooCommerce/Classes/ViewRelated/Coupons/Add and Edit Coupons/CouponCreationSuccess.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ struct CouponCreationSuccess: View {
5454
.onAppear {
5555
animateEntry()
5656
}
57+
.onDisappear {
58+
onDismiss()
59+
}
5760
}
5861

5962
private func animateEntry() {

WooCommerce/Classes/ViewRelated/Coupons/CouponDetails/CouponDetailsViewModel.swift

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,7 @@ final class CouponDetailsViewModel: ObservableObject {
9797
/// The message to be shared about the coupon
9898
///
9999
var shareMessage: String {
100-
if coupon.productIds.isNotEmpty ||
101-
coupon.productCategories.isNotEmpty ||
102-
coupon.excludedProductIds.isNotEmpty ||
103-
coupon.excludedProductCategories.isNotEmpty {
104-
return String.localizedStringWithFormat(Localization.shareMessageSomeProducts, amount, couponCode)
105-
}
106-
return String.localizedStringWithFormat(Localization.shareMessageAllProducts, amount, couponCode)
100+
coupon.generateShareMessage(currencySettings: currencySettings)
107101
}
108102

109103
/// Total number of orders that applied the coupon
@@ -286,15 +280,10 @@ private extension CouponDetailsViewModel {
286280
}
287281

288282
func createAddEditCouponViewModel(with coupon: Coupon) -> AddEditCouponViewModel {
289-
.init(existingCoupon: coupon, onCompletion: { [weak self] result in
283+
.init(existingCoupon: coupon, onSuccess: { [weak self] updatedCoupon in
290284
guard let self = self else { return }
291-
switch result {
292-
case .success(let updatedCoupon):
293-
self.updateCoupon(updatedCoupon)
294-
self.onUpdate()
295-
default:
296-
break
297-
}
285+
self.updateCoupon(updatedCoupon)
286+
self.onUpdate()
298287
})
299288
}
300289
}
@@ -305,14 +294,4 @@ private extension CouponDetailsViewModel {
305294
enum Constants {
306295
static let noLimit: Int64 = -1
307296
}
308-
enum Localization {
309-
static let shareMessageAllProducts = NSLocalizedString(
310-
"Apply %1$@ off to all products with the promo code “%2$@”.",
311-
comment: "Message to share the coupon code if it is applicable to all products. " +
312-
"Reads like: Apply 10% off to all products with the promo code “20OFF”.")
313-
static let shareMessageSomeProducts = NSLocalizedString(
314-
"Apply %1$@ off to some products with the promo code “%2$@”.",
315-
comment: "Message to share the coupon code if it is applicable to some products. " +
316-
"Reads like: Apply 10% off to some products with the promo code “20OFF”.")
317-
}
318297
}

0 commit comments

Comments
 (0)