Show the list of features included in each product (#1157)

List product features inline in the paywall. Inside the list, highlight
the required features that originated the paywall.
This commit is contained in:
Davide 2025-02-10 20:18:56 +01:00 committed by GitHub
parent 954475e117
commit 914520009a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 119 additions and 57 deletions

View File

@ -55,6 +55,7 @@ struct InstalledProfileView: View, Routable {
toggleButton
}
.modifier(HeaderModifier(layout: layout))
.unanimated()
}
}

View File

@ -786,20 +786,20 @@ public enum Strings {
}
}
}
public enum Product {
/// Included features
public static let includedFeatures = Strings.tr("Localizable", "views.paywall.product.included_features", fallback: "Included features")
}
public enum Rows {
/// Restore purchases
public static let restorePurchases = Strings.tr("Localizable", "views.paywall.rows.restore_purchases", fallback: "Restore purchases")
}
public enum Sections {
public enum IncludedFeatures {
/// Also includes
public static let header = Strings.tr("Localizable", "views.paywall.sections.included_features.header", fallback: "Also includes")
}
public enum Products {
/// All purchases support Family Sharing.
public static let footer = Strings.tr("Localizable", "views.paywall.sections.products.footer", fallback: "All purchases support Family Sharing.")
/// Available products
public static let header = Strings.tr("Localizable", "views.paywall.sections.products.header", fallback: "Available products")
/// Suggested products
public static let header = Strings.tr("Localizable", "views.paywall.sections.products.header", fallback: "Suggested products")
}
public enum RequiredFeatures {
/// Required features

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Ihre Käufe werden überprüft.";
"views.paywall.alerts.verification.connect.2" = "Falls die Überprüfung nicht abgeschlossen werden kann, wird die Verbindung in %d Minuten beendet.";
"views.paywall.alerts.verification.edit" = "Bitte warten Sie, während Ihre Käufe überprüft werden.";
"views.paywall.product.included_features" = "Enthaltene Funktionen";
"views.paywall.rows.restore_purchases" = "Käufe wiederherstellen";
"views.paywall.sections.all_features.header" = "Die Vollversion enthält";
"views.paywall.sections.full_products.header" = "Vollversion";
"views.paywall.sections.included_features.header" = "Enthält außerdem";
"views.paywall.sections.products.footer" = "Alle Käufe unterstützen die Familienfreigabe.";
"views.paywall.sections.products.header" = "Verfügbare Produkte";
"views.paywall.sections.products.header" = "Empfohlene Produkte";
"views.paywall.sections.required_features.header" = "Erforderliche Funktionen";
"views.paywall.sections.restore.footer" = "Wenn du diese App oder Funktion in der Vergangenheit gekauft hast, kannst du deine Käufe wiederherstellen.";
"views.paywall.sections.restore.header" = "Wiederherstellen";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Οι αγορές σας επαληθεύονται.";
"views.paywall.alerts.verification.connect.2" = "Αν η επαλήθευση δεν ολοκληρωθεί, η σύνδεση θα τερματιστεί σε %d λεπτά.";
"views.paywall.alerts.verification.edit" = "Παρακαλώ περιμένετε όσο επαληθεύονται οι αγορές σας.";
"views.paywall.product.included_features" = "Περιλαμβανόμενες λειτουργίες";
"views.paywall.rows.restore_purchases" = "Επαναφορά αγορών";
"views.paywall.sections.all_features.header" = "Η πλήρης έκδοση περιλαμβάνει";
"views.paywall.sections.full_products.header" = "Πλήρης έκδοση";
"views.paywall.sections.included_features.header" = "Περιλαμβάνει επίσης";
"views.paywall.sections.products.footer" = "Όλες οι αγορές υποστηρίζουν την Οικογενειακή Κοινή Χρήση.";
"views.paywall.sections.products.header" = "Διαθέσιμα προϊόντα";
"views.paywall.sections.products.header" = "Προτεινόμενα προϊόντα";
"views.paywall.sections.required_features.header" = "Απαιτούμενες λειτουργίες";
"views.paywall.sections.restore.footer" = "Εάν αγοράσατε αυτήν την εφαρμογή ή λειτουργία στο παρελθόν, μπορείτε να επαναφέρετε τις αγορές σας.";
"views.paywall.sections.restore.header" = "Επαναφορά";

View File

@ -78,12 +78,12 @@
"views.migration.alerts.delete.message" = "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@";
"views.paywall.sections.required_features.header" = "Required features";
"views.paywall.sections.included_features.header" = "Also includes";
"views.paywall.sections.products.header" = "Available products";
"views.paywall.sections.products.header" = "Suggested products";
"views.paywall.sections.products.footer" = "All purchases support Family Sharing.";
"views.paywall.sections.restore.header" = "Restore";
"views.paywall.sections.restore.footer" = "If you bought this app or feature in the past, you can restore your purchases.";
"views.paywall.rows.restore_purchases" = "Restore purchases";
"views.paywall.product.included_features" = "Included features";
"views.paywall.alerts.confirmation.title" = "Purchase required";
"views.paywall.alerts.confirmation.message" = "This profile requires paid features to work.";
"views.paywall.alerts.confirmation.message.connect" = "You may test the connection for %d minutes.";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Tus compras están siendo verificadas.";
"views.paywall.alerts.verification.connect.2" = "Si la verificación no se completa, la conexión finalizará en %d minutos.";
"views.paywall.alerts.verification.edit" = "Por favor, espera mientras verificamos tus compras.";
"views.paywall.product.included_features" = "Funciones incluidas";
"views.paywall.rows.restore_purchases" = "Restaurar compras";
"views.paywall.sections.all_features.header" = "La versión completa incluye";
"views.paywall.sections.full_products.header" = "Versión completa";
"views.paywall.sections.included_features.header" = "También incluye";
"views.paywall.sections.products.footer" = "Todas las compras admiten En Familia.";
"views.paywall.sections.products.header" = "Productos disponibles";
"views.paywall.sections.products.header" = "Productos sugeridos";
"views.paywall.sections.required_features.header" = "Características requeridas";
"views.paywall.sections.restore.footer" = "Si compraste esta app o característica en el pasado, puedes restaurar tus compras.";
"views.paywall.sections.restore.header" = "Restaurar";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Vos achats sont en cours de vérification.";
"views.paywall.alerts.verification.connect.2" = "Si la vérification ne peut être complétée, la connexion sarrêtera dans %d minutes.";
"views.paywall.alerts.verification.edit" = "Veuillez patienter pendant la vérification de vos achats.";
"views.paywall.product.included_features" = "Fonctionnalités incluses";
"views.paywall.rows.restore_purchases" = "Restaurer les achats";
"views.paywall.sections.all_features.header" = "La version complète inclut";
"views.paywall.sections.full_products.header" = "Version complète";
"views.paywall.sections.included_features.header" = "Comprend également";
"views.paywall.sections.products.footer" = "Tous les achats prennent en charge le Partage familial.";
"views.paywall.sections.products.header" = "Produits disponibles";
"views.paywall.sections.products.header" = "Produits suggérés";
"views.paywall.sections.required_features.header" = "Fonctionnalités requises";
"views.paywall.sections.restore.footer" = "Si vous avez acheté cette application ou cette fonctionnalité dans le passé, vous pouvez restaurer vos achats.";
"views.paywall.sections.restore.header" = "Restaurer";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "I tuoi acquisti sono in fase di verifica.";
"views.paywall.alerts.verification.connect.2" = "Se la verifica non può essere completata, la connessione terminerà tra %d minuti.";
"views.paywall.alerts.verification.edit" = "Attendere mentre i tuoi acquisti vengono verificati.";
"views.paywall.product.included_features" = "Funzionalità incluse";
"views.paywall.rows.restore_purchases" = "Ripristina acquisti";
"views.paywall.sections.all_features.header" = "La versione completa include";
"views.paywall.sections.full_products.header" = "Versione completa";
"views.paywall.sections.included_features.header" = "Include anche";
"views.paywall.sections.products.footer" = "Tutti gli acquisti supportano “In famiglia”.";
"views.paywall.sections.products.header" = "Prodotti disponibili";
"views.paywall.sections.products.header" = "Prodotti suggeriti";
"views.paywall.sections.required_features.header" = "Funzionalità richieste";
"views.paywall.sections.restore.footer" = "Se hai acquistato questa app o funzionalità in passato, puoi ripristinare i tuoi acquisti.";
"views.paywall.sections.restore.header" = "Ripristina";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Je aankopen worden geverifieerd.";
"views.paywall.alerts.verification.connect.2" = "Als de verificatie niet kan worden voltooid, wordt de verbinding over %d minuten beëindigd.";
"views.paywall.alerts.verification.edit" = "Even geduld terwijl we je aankopen verifiëren.";
"views.paywall.product.included_features" = "Inbegrepen functies";
"views.paywall.rows.restore_purchases" = "Aankopen herstellen";
"views.paywall.sections.all_features.header" = "De volledige versie bevat";
"views.paywall.sections.full_products.header" = "Volledige versie";
"views.paywall.sections.included_features.header" = "Bevat ook";
"views.paywall.sections.products.footer" = "Alle aankopen ondersteunen Delen met gezin.";
"views.paywall.sections.products.header" = "Beschikbare producten";
"views.paywall.sections.products.header" = "Voorgestelde producten";
"views.paywall.sections.required_features.header" = "Vereiste functies";
"views.paywall.sections.restore.footer" = "Als je deze app of functie eerder hebt gekocht, kun je je aankopen herstellen.";
"views.paywall.sections.restore.header" = "Herstellen";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Twoje zakupy są weryfikowane.";
"views.paywall.alerts.verification.connect.2" = "Jeśli weryfikacja nie zostanie zakończona, połączenie zostanie zakończone za %d minut.";
"views.paywall.alerts.verification.edit" = "Proszę czekać, trwa weryfikacja zakupów.";
"views.paywall.product.included_features" = "Uwzględnione funkcje";
"views.paywall.rows.restore_purchases" = "Przywróć zakupy";
"views.paywall.sections.all_features.header" = "Pełna wersja zawiera";
"views.paywall.sections.full_products.header" = "Pełna wersja";
"views.paywall.sections.included_features.header" = "Zawiera także";
"views.paywall.sections.products.footer" = "Wszystkie zakupy obsługują Chmurę rodzinną.";
"views.paywall.sections.products.header" = "Dostępne produkty";
"views.paywall.sections.products.header" = "Sugerowane produkty";
"views.paywall.sections.required_features.header" = "Wymagane funkcje";
"views.paywall.sections.restore.footer" = "Jeśli wcześniej kupiłeś tę aplikację lub funkcję, możesz przywrócić swoje zakupy.";
"views.paywall.sections.restore.header" = "Przywróć";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Suas compras estão sendo verificadas.";
"views.paywall.alerts.verification.connect.2" = "Se a verificação não for concluída, a conexão será encerrada em %d minutos.";
"views.paywall.alerts.verification.edit" = "Aguarde enquanto suas compras estão sendo verificadas.";
"views.paywall.product.included_features" = "Recursos incluídos";
"views.paywall.rows.restore_purchases" = "Restaurar compras";
"views.paywall.sections.all_features.header" = "A versão completa inclui";
"views.paywall.sections.full_products.header" = "Versão completa";
"views.paywall.sections.included_features.header" = "Também inclui";
"views.paywall.sections.products.footer" = "Todas as compras são compatíveis com Compartilhamento Familiar.";
"views.paywall.sections.products.header" = "Produtos disponíveis";
"views.paywall.sections.products.header" = "Produtos sugeridos";
"views.paywall.sections.required_features.header" = "Recursos necessários";
"views.paywall.sections.restore.footer" = "Se você comprou este app ou recurso no passado, pode restaurar suas compras.";
"views.paywall.sections.restore.header" = "Restaurar";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Ваши покупки проверяются.";
"views.paywall.alerts.verification.connect.2" = "Если проверка не будет завершена, соединение завершится через %d минут.";
"views.paywall.alerts.verification.edit" = "Пожалуйста, подождите, пока ваши покупки проверяются.";
"views.paywall.product.included_features" = "Включенные функции";
"views.paywall.rows.restore_purchases" = "Восстановить покупки";
"views.paywall.sections.all_features.header" = "Полная версия включает";
"views.paywall.sections.full_products.header" = "Полная версия";
"views.paywall.sections.included_features.header" = "Также включает";
"views.paywall.sections.products.footer" = "Все покупки поддерживают Семейный доступ.";
"views.paywall.sections.products.header" = "Доступные продукты";
"views.paywall.sections.products.header" = "Рекомендуемые продукты";
"views.paywall.sections.required_features.header" = "Необходимые функции";
"views.paywall.sections.restore.footer" = "Если вы уже купили это приложение или функцию в прошлом, вы можете восстановить свои покупки.";
"views.paywall.sections.restore.header" = "Восстановить";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Dina köp verifieras.";
"views.paywall.alerts.verification.connect.2" = "Om verifieringen inte kan slutföras kommer anslutningen att avslutas om %d minuter.";
"views.paywall.alerts.verification.edit" = "Vänligen vänta medan dina köp verifieras.";
"views.paywall.product.included_features" = "Inkluderade funktioner";
"views.paywall.rows.restore_purchases" = "Återställ köp";
"views.paywall.sections.all_features.header" = "Den fullständiga versionen innehåller";
"views.paywall.sections.full_products.header" = "Fullständig version";
"views.paywall.sections.included_features.header" = "Inkluderar även";
"views.paywall.sections.products.footer" = "Alla köp stöder Familjedelning.";
"views.paywall.sections.products.header" = "Tillgängliga produkter";
"views.paywall.sections.products.header" = "Föreslagna produkter";
"views.paywall.sections.required_features.header" = "Krävda funktioner";
"views.paywall.sections.restore.footer" = "Om du har köpt denna app eller funktion tidigare kan du återställa dina köp.";
"views.paywall.sections.restore.header" = "Återställ";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "Ваші покупки перевіряються.";
"views.paywall.alerts.verification.connect.2" = "Якщо перевірку не вдасться завершити, підключення завершиться через %d хвилин.";
"views.paywall.alerts.verification.edit" = "Будь ласка, зачекайте, поки ваші покупки перевіряються.";
"views.paywall.product.included_features" = "Включені функції";
"views.paywall.rows.restore_purchases" = "Відновити покупки";
"views.paywall.sections.all_features.header" = "Повна версія включає";
"views.paywall.sections.full_products.header" = "Повна версія";
"views.paywall.sections.included_features.header" = "Також включає";
"views.paywall.sections.products.footer" = "Усі покупки підтримують “Сімейний доступ”.";
"views.paywall.sections.products.header" = "Доступні продукти";
"views.paywall.sections.products.header" = "Рекомендовані продукти";
"views.paywall.sections.required_features.header" = "Необхідні функції";
"views.paywall.sections.restore.footer" = "Якщо ви раніше купували цей додаток або функцію, ви можете відновити свої покупки.";
"views.paywall.sections.restore.header" = "Відновлення";

View File

@ -260,12 +260,13 @@
"views.paywall.alerts.verification.connect.1" = "您的购买正在验证中。";
"views.paywall.alerts.verification.connect.2" = "如果无法完成验证,连接将在 %d 分钟后断开。";
"views.paywall.alerts.verification.edit" = "请稍候,您的购买正在验证中。";
"views.paywall.product.included_features" = "包含的功能";
"views.paywall.rows.restore_purchases" = "恢复购买";
"views.paywall.sections.all_features.header" = "完整版本包括";
"views.paywall.sections.full_products.header" = "完整版本";
"views.paywall.sections.included_features.header" = "还包括";
"views.paywall.sections.products.footer" = "所有购买均支持“家人共享”。";
"views.paywall.sections.products.header" = "可用产品";
"views.paywall.sections.products.header" = "推荐产品";
"views.paywall.sections.required_features.header" = "必需功能";
"views.paywall.sections.restore.footer" = "如果您过去购买过此应用或功能,可以恢复您的购买记录。";
"views.paywall.sections.restore.header" = "恢复";

View File

@ -74,6 +74,7 @@ extension Theme {
case tunnelUninstall
case tvOff
case tvOn
case undisclose
case upgrade
case warning
}
@ -135,6 +136,7 @@ extension Theme.ImageName {
return "tv"
}
case .tvOn: return "tv"
case .undisclose: return "chevron.up"
case .upgrade: return "arrow.up.circle"
case .warning: return "exclamationmark.triangle"
}

View File

@ -37,7 +37,7 @@ enum FeatureListViewStyle {
struct FeatureListView<Content>: View where Content: View {
let style: FeatureListViewStyle
let header: String
var header: String?
let features: [AppFeature]
@ -50,12 +50,7 @@ struct FeatureListView<Content>: View where Content: View {
#if !os(tvOS)
case .table:
// XXX: work around Table artifact when 1 row and no headers
if features.count > 1 {
tableView
} else {
listView
}
tableView
#endif
}
}
@ -70,10 +65,8 @@ private extension FeatureListView {
#if !os(tvOS)
var tableView: some View {
Table(features.sorted()) {
TableColumn("", content: content)
TableColumn(header ?? "", content: content)
}
.withoutColumnHeaders()
.themeSection(header: header)
}
#endif
}

View File

@ -36,6 +36,8 @@ public struct PaywallProductView: View {
private let product: InAppProduct
private let highlightedFeatures: Set<AppFeature>
@Binding
private var purchasingIdentifier: String?
@ -43,10 +45,14 @@ public struct PaywallProductView: View {
private let onError: (Error) -> Void
@State
private var isPresentingFeatures = false
public init(
iapManager: IAPManager,
style: PaywallProductViewStyle,
product: InAppProduct,
highlightedFeatures: Set<AppFeature> = [],
purchasingIdentifier: Binding<String?>,
onComplete: @escaping (String, InAppPurchaseResult) -> Void,
onError: @escaping (Error) -> Void
@ -54,12 +60,30 @@ public struct PaywallProductView: View {
self.iapManager = iapManager
self.style = style
self.product = product
self.highlightedFeatures = highlightedFeatures
_purchasingIdentifier = purchasingIdentifier
self.onComplete = onComplete
self.onError = onError
}
public var body: some View {
VStack(alignment: .leading) {
productView
Group {
includedFeaturesButton
.padding(.top, 8)
includedFeaturesList
.if(isPresentingFeatures)
}
.font(.subheadline)
}
}
}
private extension PaywallProductView {
@ViewBuilder
var productView: some View {
if #available(iOS 17, macOS 14, tvOS 17, *) {
StoreKitProductView(
style: style,
@ -79,4 +103,60 @@ public struct PaywallProductView: View {
)
}
}
var includedFeaturesButton: some View {
Button {
isPresentingFeatures.toggle()
} label: {
HStack {
Text(Strings.Views.Paywall.Product.includedFeatures)
ThemeImage(isPresentingFeatures ? .undisclose : .disclose)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.cursor(.hand)
}
var includedFeaturesList: some View {
AppProduct(rawValue: product.productIdentifier)
.map { product in
FeatureListView(
style: .list,
features: product.features,
content: featureView
)
}
}
func featureView(for feature: AppFeature) -> some View {
HStack {
ThemeImage(.marked)
.opaque(highlightedFeatures.contains(feature))
Text(feature.localizedDescription)
.fontWeight(highlightedFeatures.contains(feature) ? .bold : .regular)
.scrollableOnTV()
}
}
}
#Preview {
List {
PaywallProductView(
iapManager: .forPreviews,
style: .paywall,
product: InAppProduct(
productIdentifier: AppProduct.Features.appleTV.rawValue,
localizedTitle: "Foo",
localizedPrice: "$10",
native: nil
),
highlightedFeatures: [.appleTV],
purchasingIdentifier: .constant(nil),
onComplete: { _, _ in },
onError: { _ in }
)
}
.withMockEnvironment()
}

View File

@ -88,9 +88,6 @@ private extension PaywallView {
Form {
requiredFeaturesView
productsView
if suggestedProducts == nil {
alsoIncludedEssentialsView
}
restoreView
linksView
}
@ -123,6 +120,7 @@ private extension PaywallView {
iapManager: iapManager,
style: .paywall,
product: $0,
highlightedFeatures: requiredFeatures,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
@ -136,22 +134,6 @@ private extension PaywallView {
}
}
var alsoIncludedEssentialsView: some View {
essentialFeatures
.filter {
!requiredFeatures.contains($0)
}
.nilIfEmpty
.map {
FeatureListView(
style: featuresStyle,
header: Strings.Views.Paywall.Sections.IncludedFeatures.header,
features: $0,
content: featureView(for:)
)
}
}
var linksView: some View {
Section {
Link(Strings.Unlocalized.eula, destination: Constants.shared.websites.eula)
@ -159,14 +141,6 @@ private extension PaywallView {
}
}
var featuresStyle: FeatureListViewStyle {
#if os(iOS)
.list
#else
.table
#endif
}
func featureView(for feature: AppFeature) -> some View {
Text(feature.localizedDescription)
.scrollableOnTV()