Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Customer Center] Promotional Offers support #3968

Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion RevenueCat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerCenterPurchaseType.swift
//
// Created by Cesar de la Vega on 18/7/24.

import Foundation
import RevenueCat

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
protocol CustomerCenterPurchasesType: Sendable {

@Sendable
func customerInfo() async throws -> CustomerInfo

@Sendable
func products(_ productIdentifiers: [String]) async -> [StoreProduct]

func promotionalOffer(forProductDiscount discount: StoreProductDiscount,
product: StoreProduct) async throws -> PromotionalOffer

}
35 changes: 35 additions & 0 deletions RevenueCatUI/CustomerCenter/CustomerInfo+CurrentEntitlement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerInfo+CurrentEntitlement.swift
//
// Created by Cesar de la Vega on 17/7/24.

import Foundation
import RevenueCat

extension CustomerInfo {

/// Returns the earliest expiring iOS App Store entitlement. If this CustomerInfo contains multiple lifetime
/// entitlements and no expiring entitlements, the returned entitlement is undefined.
func earliestExpiringAppStoreEntitlement() -> EntitlementInfo? {
return self.entitlements
.active
.values
.lazy
.filter { $0.store == .appStore }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[not for this PR] I was thinking, if the user has a purchase in a different store, we could probably still get it and display whatever information is available in the "no_active" page I guess but nothing to do here right now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, will take notes of that

.sorted { lhs, rhs in
let lhsDateSeconds = lhs.expirationDate?.timeIntervalSince1970 ?? TimeInterval.greatestFiniteMagnitude
let rhsDateSeconds = rhs.expirationDate?.timeIntervalSince1970 ?? TimeInterval.greatestFiniteMagnitude
return lhsDateSeconds < rhsDateSeconds
}
.first
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ enum CustomerCenterConfigTestData {
id: "2",
title: "Request a refund",
type: .refundRequest,
detail: nil
detail: .promotionalOffer(CustomerCenterConfigData.HelpPath.PromotionalOffer(
iosOfferId: "offer_id",
eligible: true,
title: "title",
subtitle: "subtitle"
))
),
.init(
id: "3",
Expand All @@ -53,15 +58,18 @@ enum CustomerCenterConfigTestData {
options: [
.init(
id: "1",
title: "Too expensive"
title: "Too expensive",
promotionalOffer: nil
),
.init(
id: "2",
title: "Don't use the app"
title: "Don't use the app",
promotionalOffer: nil
),
.init(
id: "3",
title: "Bought by mistake"
title: "Bought by mistake",
promotionalOffer: nil
)
]
))
Expand Down
31 changes: 31 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/CustomerCenterEnvironment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerCenterEnvironment.swift
//
// Created by Cesar de la Vega on 19/7/24.

import Foundation
import RevenueCat
import SwiftUI

struct LocalizationKey: EnvironmentKey {

static let defaultValue: CustomerCenterConfigData.Localization? = nil

}

extension EnvironmentValues {

var localization: CustomerCenterConfigData.Localization? {
get { self[LocalizationKey.self] }
set { self[LocalizationKey.self] = newValue }
}

}
5 changes: 5 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/CustomerCenterError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ enum CustomerCenterError: Error {
/// Could not find information for an active subscription.
case couldNotFindSubscriptionInformation

/// Could not find offer id for any active product
case couldNotFindOfferForActiveProducts

}

extension CustomerCenterError: CustomNSError {
Expand All @@ -35,6 +38,8 @@ extension CustomerCenterError: CustomNSError {
switch self {
case .couldNotFindSubscriptionInformation:
return "Could not find information for an active subscription."
case .couldNotFindOfferForActiveProducts:
return "Could not find any product with specified offer id."
}
}

Expand Down
37 changes: 37 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/CustomerCenterPurchases.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// CustomerCenterPurchases.swift
//
// Created by Cesar de la Vega on 18/7/24.

import Foundation
import RevenueCat

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class CustomerCenterPurchases: CustomerCenterPurchasesType {

func customerInfo() async throws -> RevenueCat.CustomerInfo {
try await Purchases.shared.customerInfo()
}

func products(_ productIdentifiers: [String]) async -> [StoreProduct] {
await Purchases.shared.products(productIdentifiers)
}

func promotionalOffer(forProductDiscount discount: StoreProductDiscount,
product: StoreProduct) async throws -> PromotionalOffer {
try await Purchases.shared.promotionalOffer(forProductDiscount: discount,
product: product)
}

}
7 changes: 4 additions & 3 deletions RevenueCatUI/CustomerCenter/Data/FeedbackSurveyData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ import RevenueCat
class FeedbackSurveyData: ObservableObject {

var configuration: CustomerCenterConfigData.HelpPath.FeedbackSurvey
var action: (() -> Void)
var onOptionSelected: (() -> Void)

init(configuration: CustomerCenterConfigData.HelpPath.FeedbackSurvey, action: @escaping (() -> Void)) {
init(configuration: CustomerCenterConfigData.HelpPath.FeedbackSurvey,
onOptionSelected: @escaping (() -> Void)) {
self.configuration = configuration
self.action = action
self.onOptionSelected = onOptionSelected
}

}
Expand Down
74 changes: 74 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/LoadPromotionalOfferUseCase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// LoadPromotionalOfferUseCase.swift
//
// Created by Cesar de la Vega on 18/7/24.

import Foundation
import RevenueCat

protocol LoadPromotionalOfferUseCaseType {

func execute(
promoOfferDetails: CustomerCenterConfigData.HelpPath.PromotionalOffer
) async -> Result<PromotionalOfferData, Error>

}

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS, unavailable)
@MainActor
class LoadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType {

private let purchasesProvider: CustomerCenterPurchasesType

init(purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases()) {
self.purchasesProvider = purchasesProvider
}

func execute(
promoOfferDetails: CustomerCenterConfigData.HelpPath.PromotionalOffer
) async -> Result<PromotionalOfferData, Error> {
do {
let customerInfo = try await self.purchasesProvider.customerInfo()

guard let productIdentifier = customerInfo.earliestExpiringAppStoreEntitlement()?.productIdentifier,
let subscribedProduct = await self.purchasesProvider.products([productIdentifier]).first else {
Logger.warning(Strings.could_not_offer_for_active_subscriptions)
return .failure(CustomerCenterError.couldNotFindSubscriptionInformation)
}

guard let discount = subscribedProduct.discounts.first(where: {
$0.offerIdentifier == promoOfferDetails.iosOfferId
}) else {
Logger.warning(Strings.could_not_offer_for_active_subscriptions)
return .failure(CustomerCenterError.couldNotFindSubscriptionInformation)
}

let promotionalOffer = try await self.purchasesProvider.promotionalOffer(forProductDiscount: discount,
product: subscribedProduct)
let promotionalOfferData = PromotionalOfferData(promotionalOffer: promotionalOffer,
product: subscribedProduct,
promoOfferDetails: promoOfferDetails)
return .success(promotionalOfferData)
} catch {
Logger.warning(Strings.error_fetching_promotional_offer(error))
return .failure(CustomerCenterError.couldNotFindOfferForActiveProducts)
}
}

}

#endif
24 changes: 24 additions & 0 deletions RevenueCatUI/CustomerCenter/Data/PromotionalOfferData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// PromotionalOfferData.swift
//
// Created by Cesar de la Vega on 17/7/24.

import Foundation
import RevenueCat

struct PromotionalOfferData: Identifiable {

let id = UUID()
let promotionalOffer: PromotionalOffer
let product: StoreProduct
let promoOfferDetails: CustomerCenterConfigData.HelpPath.PromotionalOffer

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// FeedbackSurveyViewModel.swift
//
//
// Created by Cesar de la Vega on 17/6/24.
//

import Foundation
import RevenueCat

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor
class FeedbackSurveyViewModel: ObservableObject {

var feedbackSurveyData: FeedbackSurveyData

@Published
var loadingState: String?
@Published
var promotionalOfferData: PromotionalOfferData?

private var purchasesProvider: CustomerCenterPurchasesType
private let loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType

convenience init(feedbackSurveyData: FeedbackSurveyData) {
self.init(feedbackSurveyData: feedbackSurveyData,
purchasesProvider: CustomerCenterPurchases(),
loadPromotionalOfferUseCase: LoadPromotionalOfferUseCase())
}

init(feedbackSurveyData: FeedbackSurveyData,
purchasesProvider: CustomerCenterPurchasesType,
loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType) {
self.feedbackSurveyData = feedbackSurveyData
self.purchasesProvider = purchasesProvider
self.loadPromotionalOfferUseCase = loadPromotionalOfferUseCase
}

func handleAction(for option: CustomerCenterConfigData.HelpPath.FeedbackSurvey.Option) async {
if let promotionalOffer = option.promotionalOffer {
self.loadingState = option.id
let result = await loadPromotionalOfferUseCase.execute(promoOfferDetails: promotionalOffer)
switch result {
case .success(let promotionalOfferData):
self.promotionalOfferData = promotionalOfferData
case .failure:
self.feedbackSurveyData.onOptionSelected()
}
} else {
self.feedbackSurveyData.onOptionSelected()
}
}

func handleSheetDismiss() {
self.feedbackSurveyData.onOptionSelected()
self.loadingState = nil
}

}

#endif
Loading