Skip to content

Commit 02e96df

Browse files
ALFMOB-162: Improve Wishlist Functionality on PLP & Wishlist
- Created `WishlistProduct` and `BagProduct` inheriting from `SelectedProduct`. - Made `WishlistProduct` unique by **product ID and color ID**. - Made `BagProduct` unique by **product ID and variant SKU**. - Removed size information from `WishlistView` and `ProductListingView`. - Updated **"Add to Bag"** action in `WishlistView` to redirect users to **PDP** for size selection instead of adding directly to the bag. - Disabled **"Add to Bag"** button on **PDP** until both **color** (pre-selected) and **size** have been selected.
1 parent 9c8b5c6 commit 02e96df

29 files changed

+249
-104
lines changed

Alfie/Alfie/Localization/L10n+Generated.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ enum L10n {
192192
enum Size {
193193
/// Size
194194
static let title = L10n.tr("L10n", "product.size.title")
195+
enum NoSelection {
196+
/// Select a size
197+
static let title = L10n.tr("L10n", "product.size.no_selection.title")
198+
}
195199
}
196200
}
197201
enum Search {
@@ -471,6 +475,7 @@ extension L10n {
471475
case productOneSizeTitle = "product.one_size.title"
472476
case productOutOfStockButtonCta = "product.out_of_stock.button.cta"
473477
case productSizeTitle = "product.size.title"
478+
case productSizeNoSelectionTitle = "product.size.no_selection.title"
474479
case searchScreenEmptyViewMessage = "search.screen.empty_view.message"
475480
case searchScreenEmptyViewTitle = "search.screen.empty_view.title"
476481
case searchScreenNoResultsViewLink = "search.screen.no_results_view.link"

Alfie/Alfie/Localization/L10n.xcstrings

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,17 @@
409409
}
410410
}
411411
},
412+
"product.size.no_selection.title" : {
413+
"extractionState" : "manual",
414+
"localizations" : {
415+
"en" : {
416+
"stringUnit" : {
417+
"state" : "translated",
418+
"value" : "Select a size"
419+
}
420+
}
421+
}
422+
},
412423
"product.size.title" : {
413424
"extractionState" : "manual",
414425
"localizations" : {

Alfie/Alfie/Views/BagView/BagViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import Models
33

44
final class BagViewModel: BagViewModelProtocol {
5-
@Published private(set) var products: [SelectedProduct]
5+
@Published private(set) var products: [BagProduct]
66

77
private let dependencies: BagDependencyContainer
88

@@ -17,13 +17,13 @@ final class BagViewModel: BagViewModelProtocol {
1717
products = dependencies.bagService.getBagContent()
1818
}
1919

20-
func didSelectDelete(for selectedProduct: SelectedProduct) {
20+
func didSelectDelete(for selectedProduct: BagProduct) {
2121
dependencies.bagService.removeProduct(selectedProduct)
2222
products = dependencies.bagService.getBagContent()
2323
dependencies.analytics.trackRemoveFromBag(productID: selectedProduct.product.id)
2424
}
2525

26-
func productCardViewModel(for selectedProduct: SelectedProduct) -> HorizontalProductCardViewModel {
26+
func productCardViewModel(for selectedProduct: BagProduct) -> HorizontalProductCardViewModel {
2727
.init(
2828
image: selectedProduct.media.first?.asImage?.url,
2929
designer: selectedProduct.brand.name,

Alfie/Alfie/Views/ProductDetails/ProductDetailsView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ extension ProductDetailsView {
489489
ThemedButton(
490490
text: viewModel.productHasStock ? addToBagText : outOfStockText,
491491
isDisabled: .init(
492-
get: { !viewModel.productHasStock },
492+
get: { !viewModel.isAddToBagEnabled },
493493
set: { _ in }
494494
),
495495
isFullWidth: true

Alfie/Alfie/Views/ProductDetails/ProductDetailsViewModel.swift

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
2727

2828
private var selectedVariant: Product.Variant? {
2929
guard case .success(let model) = state else {
30-
return initialSelectedProduct?.selectedVariant ?? baseProduct?.defaultVariant
30+
return initialSelectedProduct?.selectedVariant ?? baseProduct?.defaultVariantWithoutSize
3131
}
3232

3333
return model.selectedVariant
@@ -88,6 +88,18 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
8888
product?.priceType
8989
}
9090

91+
var isAddToBagEnabled: Bool {
92+
productHasStock && hasColorSelected && hasSizeSelected
93+
}
94+
95+
private var hasColorSelected: Bool {
96+
colorSelectionConfiguration.items.count > 1 ? selectedVariant?.colour != nil : true
97+
}
98+
99+
private var hasSizeSelected: Bool {
100+
sizingSelectionConfiguration.items.count > 1 ? selectedVariant?.size != nil : true
101+
}
102+
91103
init(
92104
productDetailsConfiguration: ProductDetailsConfiguration,
93105
dependencies: ProductDetailsDependencyContainer
@@ -107,7 +119,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
107119

108120
buildColorAndSizingSelectionConfigurations(
109121
product: product,
110-
selectedVariant: product.defaultVariant
122+
selectedVariant: product.defaultVariantWithoutSize
111123
)
112124

113125
case .selectedProduct(let selectedProduct):
@@ -184,14 +196,16 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
184196

185197
func didTapAddToBag() {
186198
guard let selectedProduct else { return }
187-
dependencies.bagService.addProduct(selectedProduct)
188-
dependencies.analytics.trackAddToBag(productID: selectedProduct.id)
199+
let bagProduct = BagProduct(selectedProduct: selectedProduct)
200+
dependencies.bagService.addProduct(bagProduct)
201+
dependencies.analytics.trackAddToBag(productID: bagProduct.id)
189202
}
190203

191204
func didTapAddToWishlist() {
192205
guard let selectedProduct else { return }
193-
dependencies.wishlistService.addProduct(selectedProduct)
194-
dependencies.analytics.trackAddToWishlist(productID: selectedProduct.id)
206+
let wishlistProduct = WishlistProduct(selectedProduct: selectedProduct)
207+
dependencies.wishlistService.addProduct(wishlistProduct)
208+
dependencies.analytics.trackAddToWishlist(productID: wishlistProduct.id)
195209
}
196210

197211
func colorSwatches(filteredBy searchTerm: String) -> [ColorSwatch] {
@@ -226,7 +240,7 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
226240
return
227241
}
228242

229-
let selectedVariant = initialSelectedProduct?.selectedVariant ?? product.defaultVariant
243+
let selectedVariant = initialSelectedProduct?.selectedVariant ?? product.defaultVariantWithoutSize
230244
buildColorAndSizingSelectionConfigurations(product: product, selectedVariant: selectedVariant)
231245
state = .success(.init(product: product, selectedVariant: selectedVariant))
232246
}
@@ -306,7 +320,8 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
306320
sizingSelectionConfiguration = .init(
307321
selectedTitle: L10n.Product.Size.title + ":",
308322
items: sizingSwatches,
309-
selectedItem: selectedSwatch
323+
selectedItem: selectedSwatch,
324+
noItemSelectedTitle: L10n.Product.Size.NoSelection.title
310325
)
311326
sizingSelectionSubscription = sizingSelectionConfiguration.$selectedItem
312327
.receive(on: DispatchQueue.main)
@@ -359,14 +374,26 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
359374
}
360375

361376
guard let variant = product.variants.first(
362-
where: { $0.colour?.id == colorSwatch.id && $0.size?.id == selectedVariant?.size?.id }
377+
where: {
378+
$0.colour?.id == colorSwatch.id
379+
&& (selectedVariant?.size?.id == nil || $0.size?.id == selectedVariant?.size?.id)
380+
}
363381
)
364382
else {
365383
log.debug("Unexpected data inconsistency: tried to select color \(colorSwatch.id) on product \(productId) but no variant exists with that color, ignoring selection")
366384
return
367385
}
368386

369-
state = .success(.init(product: product, selectedVariant: variant))
387+
let updatedVariant = Product.Variant(
388+
sku: variant.sku,
389+
size: selectedVariant?.size, // Making sure if no size selected, it will not auto select
390+
colour: variant.colour,
391+
attributes: variant.attributes,
392+
stock: variant.stock,
393+
price: variant.price
394+
)
395+
396+
state = .success(.init(product: product, selectedVariant: updatedVariant))
370397
}
371398

372399
private func didSelect(sizingSwatch: SizingSwatch) {
@@ -376,14 +403,26 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
376403
}
377404

378405
guard let variant = product.variants.first(
379-
where: { $0.size?.id == sizingSwatch.id && $0.colour?.id == selectedVariant?.colour?.id }
406+
where: {
407+
$0.size?.id == sizingSwatch.id
408+
&& (selectedVariant?.colour?.id == nil || $0.colour?.id == selectedVariant?.colour?.id)
409+
}
380410
)
381411
else {
382412
log.debug("Unexpected data inconsistency: tried to select size \(sizingSwatch.id) on product \(productId) but no variant exists with that size, ignoring selection")
383413
return
384414
}
385415

386-
state = .success(.init(product: product, selectedVariant: variant))
416+
let updatedVariant = Product.Variant(
417+
sku: variant.sku,
418+
size: variant.size,
419+
colour: selectedVariant?.colour, // Making sure if no color selected, it will not auto select
420+
attributes: variant.attributes,
421+
stock: variant.stock,
422+
price: variant.price
423+
)
424+
425+
state = .success(.init(product: product, selectedVariant: updatedVariant))
387426
}
388427

389428
private var selectedProduct: SelectedProduct? {
@@ -396,4 +435,4 @@ final class ProductDetailsViewModel: ProductDetailsViewModelProtocol {
396435

397436
return SelectedProduct(product: product, selectedVariant: selectedVariant)
398437
}
399-
}
438+
} // swiftlint:disable:this file_length

Alfie/Alfie/Views/ProductListing/ProductListingView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ struct ProductListingView<ViewModel: ProductListingViewModelProtocol>: View {
7272
viewModel: .init(
7373
configuration: .init(
7474
size: viewModel.style == .list ? .large : .medium,
75+
hideSize: true,
7576
hideAction: !coordinator.isWishlistEnabled
7677
),
7778
product: product

Alfie/Alfie/Views/ProductListing/ProductListingViewModel.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,18 +79,18 @@ final class ProductListingViewModel: ProductListingViewModelProtocol {
7979
func didSelect(_: Product) {}
8080

8181
func isFavoriteState(for product: Product) -> Bool {
82-
wishlistContent.contains { $0.product.defaultVariant.sku == product.defaultVariant.sku }
82+
wishlistContent.contains { $0.product.id == product.id }
8383
}
8484

8585
func didTapAddToWishlist(for product: Product, isFavorite: Bool) {
8686
if !isFavorite {
87-
let selectedProduct = SelectedProduct(product: product)
88-
dependencies.wishlistService.addProduct(selectedProduct)
89-
dependencies.analytics.trackAddToWishlist(productID: product.id)
87+
let wishlistProduct = WishlistProduct(product: product)
88+
dependencies.wishlistService.addProduct(wishlistProduct)
89+
dependencies.analytics.trackAddToWishlist(productID: wishlistProduct.id)
9090
} else {
91-
let selectedProduct = SelectedProduct(product: product)
92-
dependencies.wishlistService.removeProduct(selectedProduct)
93-
dependencies.analytics.trackRemoveFromWishlist(productID: product.id)
91+
let wishlistProduct = WishlistProduct(product: product)
92+
dependencies.wishlistService.removeProductVariants(wishlistProduct)
93+
dependencies.analytics.trackRemoveFromWishlist(productID: wishlistProduct.id)
9494
}
9595
wishlistContent = dependencies.wishlistService.getWishlistContent()
9696
}

Alfie/Alfie/Views/WishlistView/WishlistView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@ struct WishlistView<ViewModel: WishlistViewModelProtocol>: View {
5050
// MARK: - Private Methods
5151

5252
private extension WishlistView {
53-
func handleUserAction(forProduct product: SelectedProduct, actionType: VerticalProductCard.ProductUserActionType) {
53+
func handleUserAction(forProduct product: WishlistProduct, actionType: VerticalProductCard.ProductUserActionType) {
5454
// swiftlint:disable vertical_whitespace_between_cases
5555
switch actionType {
5656
case .remove:
5757
viewModel.didSelectDelete(for: product)
5858
case .addToBag:
59-
viewModel.didTapAddToBag(for: product)
59+
coordinador.openDetails(for: product)
6060
case .wishlist:
6161
return
6262
}

Alfie/Alfie/Views/WishlistView/WishlistViewModel.swift

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import Models
33

44
final class WishlistViewModel: WishlistViewModelProtocol {
5-
@Published private(set) var products: [SelectedProduct]
5+
@Published private(set) var products: [WishlistProduct]
66

77
private let dependencies: WishlistDependencyContainer
88

@@ -17,32 +17,27 @@ final class WishlistViewModel: WishlistViewModelProtocol {
1717
products = dependencies.wishlistService.getWishlistContent()
1818
}
1919

20-
func didSelectDelete(for selectedProduct: SelectedProduct) {
21-
dependencies.wishlistService.removeProduct(selectedProduct)
22-
dependencies.analytics.trackRemoveFromWishlist(productID: selectedProduct.product.id)
20+
func didSelectDelete(for wishlistProduct: WishlistProduct) {
21+
dependencies.wishlistService.removeProduct(wishlistProduct)
22+
dependencies.analytics.trackRemoveFromWishlist(productID: wishlistProduct.product.id)
2323
products = dependencies.wishlistService.getWishlistContent()
2424
}
2525

26-
func didTapAddToBag(for selectedProduct: SelectedProduct) {
27-
dependencies.bagService.addProduct(selectedProduct)
28-
dependencies.analytics.trackAddToBag(productID: selectedProduct.product.id)
29-
}
30-
31-
func productCardViewModel(for selectedProduct: SelectedProduct) -> VerticalProductCardViewModel {
26+
func productCardViewModel(for wishlistProduct: WishlistProduct) -> VerticalProductCardViewModel {
3227
.init(
33-
configuration: .init(size: .medium, hideDetails: false, actionType: .remove),
34-
productId: selectedProduct.id,
35-
image: selectedProduct.media.first?.asImage?.url,
36-
designer: selectedProduct.brand.name,
37-
name: selectedProduct.name,
38-
priceType: selectedProduct.priceType,
28+
configuration: .init(size: .medium, hideSize: true, actionType: .remove),
29+
productId: wishlistProduct.id,
30+
image: wishlistProduct.media.first?.asImage?.url,
31+
designer: wishlistProduct.brand.name,
32+
name: wishlistProduct.name,
33+
priceType: wishlistProduct.priceType,
3934
colorTitle: L10n.Product.Color.title + ":",
40-
color: selectedProduct.colour?.name ?? "",
35+
color: wishlistProduct.colour?.name ?? "",
4136
sizeTitle: L10n.Product.Size.title + ":",
42-
size: selectedProduct.size == nil ? L10n.Product.OneSize.title : selectedProduct.sizeText,
37+
size: wishlistProduct.size == nil ? L10n.Product.OneSize.title : wishlistProduct.sizeText,
4338
addToBagTitle: L10n.Product.AddToBag.Button.cta,
4439
outOfStockTitle: L10n.Product.OutOfStock.Button.cta,
45-
isAddToBagDisabled: selectedProduct.stock == .zero
40+
isAddToBagDisabled: wishlistProduct.stock == .zero
4641
)
4742
}
4843
}

Alfie/AlfieKit/Sources/Core/Services/Bag/BagService.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@ import Models
33

44
// TODO: Update with an actual implementation with storage
55
public final class BagService: BagServiceProtocol {
6-
private var products: [SelectedProduct] = []
6+
private var products: [BagProduct] = []
77

88
public init() { }
99

10-
public func addProduct(_ product: SelectedProduct) {
11-
guard !products.contains(where: { $0.id == product.id }) else { return }
10+
public func addProduct(_ bagProduct: BagProduct) {
11+
guard !products.contains(where: { $0.id == bagProduct.id }) else { return }
1212

13-
products.append(product)
13+
products.append(bagProduct)
1414
}
1515

16-
public func removeProduct(_ product: SelectedProduct) {
17-
products = products.filter { $0.id != product.id }
16+
public func removeProduct(_ bagProduct: BagProduct) {
17+
products = products.filter { $0.id != bagProduct.id }
1818
}
1919

20-
public func getBagContent() -> [SelectedProduct] {
20+
public func getBagContent() -> [BagProduct] {
2121
products
2222
}
2323
}

0 commit comments

Comments
 (0)