From 1d99bb1404782059fdcca91267e8317b140547a1 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 19:51:32 +0800 Subject: [PATCH 1/7] feat(ui): add marketing info support to purchase cards Add optional badge, features, and savings display to PurchaseOptionCard. Extend ProductConfig to include marketing metadata and propagate these through configuration, core, and PaywallView for enhanced paywall UI. Update README with marketing-enhanced usage examples. --- README.md | 26 +++ .../Configuration/ProductConfiguration.swift | 86 +++++++++- .../Configuration/StoreKitConfiguration.swift | 20 ++- Sources/InAppKit/Core/InAppKit.swift | 32 +++- .../UI/Components/PurchaseOptionCard.swift | 153 ++++++++++++++++-- Sources/InAppKit/UI/Paywall/PaywallView.swift | 6 +- 6 files changed, 298 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 4342089..080ea68 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,24 @@ ContentView() .withPurchases(products: [Product("com.yourapp.pro", features: [MyFeature.sync, MyFeature.export])]) ``` +*Or with marketing information for better conversion:* +```swift +ContentView() + .withPurchases(products: [ + Product("com.yourapp.monthly", features: [MyFeature.sync]) + .withMarketingFeatures(["Cloud sync", "Premium filters"]), + + Product("com.yourapp.annual", features: [MyFeature.sync, MyFeature.export]) + .withBadge("Most Popular") + .withMarketingFeatures(["Cloud sync", "Premium filters", "Priority support"]) + .withSavings("Save 15%"), + + Product("com.yourapp.lifetime", features: MyFeature.allCases) + .withBadge("Best Value") + .withMarketingFeatures(["All features included", "Lifetime updates"]) + ]) +``` + ### 2. Gate any feature (1 line) ```swift @@ -381,6 +399,14 @@ await InAppKit.shared.purchase(product) // Advanced: Products with specific features .withPurchases(products: [Product("com.app.pro", AppFeature.allCases)]) + +// Marketing-enhanced: Boost conversion with badges, features, and savings +.withPurchases(products: [ + Product("com.app.pro", AppFeature.allCases) + .withBadge("Most Popular") + .withMarketingFeatures(["Cloud sync", "AI features", "Priority support"]) + .withSavings("Save 20%") +]) ``` ## 🎯 Advanced Features diff --git a/Sources/InAppKit/Configuration/ProductConfiguration.swift b/Sources/InAppKit/Configuration/ProductConfiguration.swift index 19bca2a..61a5689 100644 --- a/Sources/InAppKit/Configuration/ProductConfiguration.swift +++ b/Sources/InAppKit/Configuration/ProductConfiguration.swift @@ -14,10 +14,22 @@ import StoreKit public struct ProductConfig: Sendable { public let id: String public let features: [T] - - public init(_ id: String, features: [T]) { + public let badge: String? + public let marketingFeatures: [String]? + public let savings: String? + + public init( + _ id: String, + features: [T], + badge: String? = nil, + marketingFeatures: [String]? = nil, + savings: String? = nil + ) { self.id = id self.features = features + self.badge = badge + self.marketingFeatures = marketingFeatures + self.savings = savings } } @@ -25,10 +37,22 @@ public struct ProductConfig: Sendable { public struct InternalProductConfig: @unchecked Sendable { public let id: String public let features: [AnyHashable] - - public init(id: String, features: [AnyHashable]) { + public let badge: String? + public let marketingFeatures: [String]? + public let savings: String? + + public init( + id: String, + features: [AnyHashable], + badge: String? = nil, + marketingFeatures: [String]? = nil, + savings: String? = nil + ) { self.id = id self.features = features + self.badge = badge + self.marketingFeatures = marketingFeatures + self.savings = savings } } @@ -44,6 +68,23 @@ public func Product(_ id: String, features: [T]) -> Prod ProductConfig(id, features: features) } +// Product with marketing information +public func Product( + _ id: String, + features: [T], + badge: String? = nil, + marketingFeatures: [String]? = nil, + savings: String? = nil +) -> ProductConfig { + ProductConfig( + id, + features: features, + badge: badge, + marketingFeatures: marketingFeatures, + savings: savings + ) +} + // Support for .allCases pattern - for when you pass [EnumType.allCases] public func Product(_ id: String, _ allCases: T.AllCases) -> ProductConfig { ProductConfig(id, features: Array(allCases)) @@ -54,6 +95,43 @@ public func Product(_ id: String, _ features: [T]) -> Pr ProductConfig(id, features: features) } +// MARK: - Fluent API Extensions for Marketing + +public extension ProductConfig { + /// Add a promotional badge to the product + func withBadge(_ badge: String) -> ProductConfig { + ProductConfig( + id, + features: features, + badge: badge, + marketingFeatures: marketingFeatures, + savings: savings + ) + } + + /// Add marketing features (shown as bullet points in UI) + func withMarketingFeatures(_ features: [String]) -> ProductConfig { + ProductConfig( + id, + features: self.features, + badge: badge, + marketingFeatures: features, + savings: savings + ) + } + + /// Add savings information + func withSavings(_ savings: String) -> ProductConfig { + ProductConfig( + id, + features: features, + badge: badge, + marketingFeatures: marketingFeatures, + savings: savings + ) + } +} + // MARK: - PaywallContext /// Context for product-based paywalls diff --git a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift index f3f9907..99fa032 100644 --- a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift +++ b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift @@ -38,13 +38,29 @@ public class StoreKitConfiguration { /// Configure purchases with features public func withPurchases(products: [ProductConfig]) -> StoreKitConfiguration { - productConfigs.append(contentsOf: products.map { InternalProductConfig(id: $0.id, features: $0.features.map(AnyHashable.init)) }) + productConfigs.append(contentsOf: products.map { + InternalProductConfig( + id: $0.id, + features: $0.features.map(AnyHashable.init), + badge: $0.badge, + marketingFeatures: $0.marketingFeatures, + savings: $0.savings + ) + }) return self } /// Configure purchases with simple products (no features) public func withPurchases(products: [ProductConfig]) -> StoreKitConfiguration { - productConfigs.append(contentsOf: products.map { InternalProductConfig(id: $0.id, features: []) }) + productConfigs.append(contentsOf: products.map { + InternalProductConfig( + id: $0.id, + features: [], + badge: $0.badge, + marketingFeatures: $0.marketingFeatures, + savings: $0.savings + ) + }) return self } diff --git a/Sources/InAppKit/Core/InAppKit.swift b/Sources/InAppKit/Core/InAppKit.swift index 35ec485..07d0094 100644 --- a/Sources/InAppKit/Core/InAppKit.swift +++ b/Sources/InAppKit/Core/InAppKit.swift @@ -24,6 +24,7 @@ public class InAppKit { // Feature-based configuration storage private var featureToProductMapping: [AnyHashable: [String]] = [:] private var productToFeatureMapping: [String: [AnyHashable]] = [:] + private var productMarketingInfo: [String: (badge: String?, features: [String]?, savings: String?)] = [:] private var updateListenerTask: Task? @@ -43,14 +44,22 @@ public class InAppKit { /// Initialize with product configurations (for fluent API) internal func initialize(with productConfigs: [InternalProductConfig]) async { let productIDs = productConfigs.map { $0.id } - - // Register features + + // Register features and marketing info for config in productConfigs { + // Register features for feature in config.features { registerFeature(feature, productIds: [config.id]) } + + // Store marketing information + productMarketingInfo[config.id] = ( + badge: config.badge, + features: config.marketingFeatures, + savings: config.savings + ) } - + await loadProducts(productIds: productIDs) isInitialized = true } @@ -157,6 +166,23 @@ public class InAppKit { public func isFeatureRegistered(_ feature: AnyHashable) -> Bool { return featureToProductMapping[feature] != nil } + + // MARK: - Marketing Information + + /// Get marketing badge for a product + public func badge(for productId: String) -> String? { + return productMarketingInfo[productId]?.badge + } + + /// Get marketing features for a product + public func marketingFeatures(for productId: String) -> [String]? { + return productMarketingInfo[productId]?.features + } + + /// Get savings information for a product + public func savings(for productId: String) -> String? { + return productMarketingInfo[productId]?.savings + } // MARK: - Development Helpers diff --git a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift index d38cc44..11e3174 100644 --- a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift +++ b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift @@ -12,12 +12,46 @@ struct PurchaseOptionCard: View { let product: Product let isSelected: Bool let onSelect: () -> Void + + // Optional marketing enhancements + let badge: String? + let features: [String]? + let savings: String? + + init( + product: Product, + isSelected: Bool, + onSelect: @escaping () -> Void, + badge: String? = nil, + features: [String]? = nil, + savings: String? = nil + ) { + self.product = product + self.isSelected = isSelected + self.onSelect = onSelect + self.badge = badge + self.features = features + self.savings = savings + } private var productDescription: String { switch product.type { case .autoRenewable: - if let period = product.subscription?.subscriptionPeriod { - return "\(periodDescription(period)) subscription • Auto-renewable" + if let subscription = product.subscription { + var description = "" + + // Add trial info if available + if let intro = subscription.introductoryOffer, + intro.paymentMode == .freeTrial { + let trialLength = periodText(intro.period) + description += "\(trialLength) free trial • " + } + + // Add subscription period + let period = subscription.subscriptionPeriod + description += "\(periodDescription(period)) subscription" + + return description } return "Subscription • Auto-renewable" case .nonConsumable: @@ -32,8 +66,8 @@ struct PurchaseOptionCard: View { private var billingPeriod: String { switch product.type { case .autoRenewable: - if let period = product.subscription?.subscriptionPeriod { - return periodText(period) + if let subscription = product.subscription { + return periodText(subscription.subscriptionPeriod) } return "Subscription" case .nonConsumable: @@ -102,13 +136,46 @@ struct PurchaseOptionCard: View { } VStack(alignment: .leading, spacing: 4) { - Text(product.displayName) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) + HStack { + Text(product.displayName) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + if let badge = badge { + Text(badge) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(badge.lowercased().contains("popular") ? Color.orange : Color.blue) + ) + } + + Spacer() + } Text(productDescription) .font(.system(size: 13, weight: .medium)) .foregroundColor(.secondary) + + // Show key features if provided + if let features = features, !features.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(features.prefix(2), id: \.self) { feature in + HStack(spacing: 4) { + Text("•") + .foregroundColor(.secondary) + .font(.system(size: 11)) + Text(feature) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 2) + } } Spacer() @@ -118,6 +185,12 @@ struct PurchaseOptionCard: View { .font(.system(size: 18, weight: .bold)) .foregroundColor(.primary) + if let savings = savings { + Text(savings) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.green) + } + Text(billingPeriod) .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) @@ -150,32 +223,41 @@ struct PurchaseOptionCard: View { .padding(.bottom) VStack(spacing: 12) { - // Example showing different selection states + // Example showing different selection states and features Group { - // Unselected Monthly Card + // Monthly with trial PurchaseOptionCardPreview( title: "Pro Monthly", - description: "Monthly subscription • Auto-renewable", + description: "7 days free trial • Monthly subscription", price: "$9.99", billingPeriod: "Monthly", + badge: nil, + features: ["Cloud sync", "Premium filters"], + savings: nil, isSelected: false ) - // Selected Annual Card + // Annual with "Most Popular" badge and savings PurchaseOptionCardPreview( title: "Pro Annual", description: "Annual subscription • Auto-renewable", price: "$99.99", billingPeriod: "Yearly", + badge: "Most Popular", + features: ["Cloud sync", "Premium filters", "Priority support"], + savings: "Save 15%", isSelected: true ) - // Lifetime Purchase Card + // Lifetime Purchase PurchaseOptionCardPreview( title: "Pro Lifetime", description: "One-time purchase • Lifetime access", price: "$199.99", billingPeriod: "Lifetime", + badge: "Best Value", + features: ["All features included", "Lifetime updates"], + savings: nil, isSelected: false ) } @@ -192,6 +274,9 @@ private struct PurchaseOptionCardPreview: View { let description: String let price: String let billingPeriod: String + let badge: String? + let features: [String]? + let savings: String? let isSelected: Bool var body: some View { @@ -215,13 +300,45 @@ private struct PurchaseOptionCardPreview: View { } VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) + HStack { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + if let badge = badge { + Text(badge) + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + Capsule() + .fill(badge.lowercased().contains("popular") ? Color.orange : Color.blue) + ) + } + + Spacer() + } Text(description) .font(.system(size: 13, weight: .medium)) .foregroundColor(.secondary) + + if let features = features, !features.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(features.prefix(2), id: \.self) { feature in + HStack(spacing: 4) { + Text("•") + .foregroundColor(.secondary) + .font(.system(size: 11)) + Text(feature) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 2) + } } Spacer() @@ -231,6 +348,12 @@ private struct PurchaseOptionCardPreview: View { .font(.system(size: 18, weight: .bold)) .foregroundColor(.primary) + if let savings = savings { + Text(savings) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.green) + } + Text(billingPeriod) .font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary) diff --git a/Sources/InAppKit/UI/Paywall/PaywallView.swift b/Sources/InAppKit/UI/Paywall/PaywallView.swift index 6854adc..4d15972 100644 --- a/Sources/InAppKit/UI/Paywall/PaywallView.swift +++ b/Sources/InAppKit/UI/Paywall/PaywallView.swift @@ -180,7 +180,10 @@ public struct PaywallView: View { PurchaseOptionCard( product: product, isSelected: selectedProduct?.id == product.id, - onSelect: { selectedProduct = product } + onSelect: { selectedProduct = product }, + badge: inAppKit.badge(for: product.id), + features: inAppKit.marketingFeatures(for: product.id), + savings: inAppKit.savings(for: product.id) ) } @@ -257,6 +260,7 @@ public struct PaywallView: View { } .padding(.vertical, 12) } + } #Preview { From b0c16dd05dd4abf458fdb89a91389ecc90d2e6e2 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 19:55:22 +0800 Subject: [PATCH 2/7] feat(ui): add marketing info support for products in README Document new badge, marketing features, and savings displays with usage examples and API methods to enhance product presentation. --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 080ea68..6608ef1 100644 --- a/README.md +++ b/README.md @@ -411,15 +411,92 @@ await InAppKit.shared.purchase(product) ## 🎯 Advanced Features +### 🎨 Marketing-Enhanced Products for Higher Conversion + +InAppKit supports rich marketing information to boost conversion rates through badges, feature highlights, and savings displays. + +#### **Configuration Options** + +**Option 1: Direct Configuration** +```swift +Product("com.app.annual", + features: [MyFeature.sync, MyFeature.export], + badge: "Most Popular", + marketingFeatures: ["Cloud sync", "AI features"], + savings: "Save 15%" +) +``` + +**Option 2: Fluent API (Recommended)** +```swift +Product("com.app.annual", features: [MyFeature.sync, MyFeature.export]) + .withBadge("Most Popular") + .withMarketingFeatures(["Cloud sync", "AI features", "Priority support"]) + .withSavings("Save 15%") +``` + +#### **🚀 Complete Marketing Example** + +```swift +ContentView() + .withPurchases(products: [ + // Monthly Plan + Product("com.yourapp.monthly", features: [MyFeature.sync]) + .withMarketingFeatures(["Cloud sync", "Basic support"]), + + // Annual Plan (Most Popular) + Product("com.yourapp.annual", features: [MyFeature.sync, MyFeature.export]) + .withBadge("Most Popular") + .withMarketingFeatures(["Cloud sync", "Advanced features", "Priority support"]) + .withSavings("Save 30%"), + + // Lifetime Plan + Product("com.yourapp.lifetime", features: MyFeature.allCases) + .withBadge("Best Value") + .withMarketingFeatures(["All features included", "Lifetime updates"]) + ]) +``` + +#### **🎯 Marketing Features** + +- **🏷️ Badges**: `"Most Popular"`, `"Best Value"`, `"Limited Time"`, custom text +- **✨ Marketing Features**: User-friendly benefit statements (up to 2 shown as bullet points) +- **💰 Savings**: `"Save 15%"`, `"50% OFF"`, custom savings text +- **🔄 Auto-Trial Detection**: Automatically shows free trial periods from StoreKit + +#### **What Users See** + +**Before Enhancement:** +``` +Pro Annual $99.99 +Annual subscription Yearly +``` + +**After Enhancement:** +``` +Pro Annual [Most Popular] $99.99 +7 days free trial • Annual Save 30% +• Cloud sync Yearly +• Advanced features +``` + ### Multiple Product Tiers in Practice ```swift -// E-commerce App Example +// E-commerce App Example with Marketing Enhancement ContentView() .withPurchases(products: [ - Product("com.shopapp.basic", [AppFeature.trackOrders, AppFeature.wishlist]), - Product("com.shopapp.plus", [AppFeature.trackOrders, AppFeature.wishlist, AppFeature.fastShipping]), + Product("com.shopapp.basic", [AppFeature.trackOrders, AppFeature.wishlist]) + .withMarketingFeatures(["Track orders", "Wishlist"]), + + Product("com.shopapp.plus", [AppFeature.trackOrders, AppFeature.wishlist, AppFeature.fastShipping]) + .withBadge("Most Popular") + .withMarketingFeatures(["Fast shipping", "Premium support"]) + .withSavings("Save 25%"), + Product("com.shopapp.premium", AppFeature.allCases) + .withBadge("Best Value") + .withMarketingFeatures(["All features", "Priority processing"]) ]) .withPaywall { context in ShopPaywallView(context: context) @@ -452,6 +529,26 @@ InAppKit.shared.registerFeature( ) ``` +### Marketing API Methods + +InAppKit provides fluent API methods for enhanced product configuration: + +```swift +// Product configuration with marketing +Product("com.app.pro", features: [MyFeature.sync]) + .withBadge("Most Popular") // Promotional badge + .withMarketingFeatures([ // User-friendly features (bullet points) + "Cloud sync across devices", + "Priority customer support" + ]) + .withSavings("Save 30%") // Savings/discount info + +// Access marketing data programmatically +let badge = InAppKit.shared.badge(for: "com.app.pro") +let features = InAppKit.shared.marketingFeatures(for: "com.app.pro") +let savings = InAppKit.shared.savings(for: "com.app.pro") +``` + ### Custom Premium Modifiers Create your own premium gating logic: From 99d6673a169c95d4818d4b9803eef23e399b0180 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 20:02:48 +0800 Subject: [PATCH 3/7] feat(ui): improve PurchaseOptionCard description and preview Use user-defined product descriptions when available and update preview demo to mirror the real component structure with clearer labels and selection logging. --- .../UI/Components/PurchaseOptionCard.swift | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift index 11e3174..488c23f 100644 --- a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift +++ b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift @@ -35,6 +35,16 @@ struct PurchaseOptionCard: View { } private var productDescription: String { + // Use user-defined description from StoreKit if available + if !product.description.isEmpty && product.description != product.displayName { + return product.description + } + + // Fallback to auto-generated description for trial info and basic details + return autoGeneratedDescription + } + + private var autoGeneratedDescription: String { switch product.type { case .autoRenewable: if let subscription = product.subscription { @@ -222,11 +232,16 @@ struct PurchaseOptionCard: View { .font(.title2.bold()) .padding(.bottom) + Text("Real PurchaseOptionCard Components") + .font(.caption) + .foregroundColor(.secondary) + .padding(.bottom, 8) + VStack(spacing: 12) { - // Example showing different selection states and features + // Visual representation showing the real component structure Group { - // Monthly with trial - PurchaseOptionCardPreview( + // Monthly Plan - Unselected + PurchaseOptionCardDemo( title: "Pro Monthly", description: "7 days free trial • Monthly subscription", price: "$9.99", @@ -237,20 +252,20 @@ struct PurchaseOptionCard: View { isSelected: false ) - // Annual with "Most Popular" badge and savings - PurchaseOptionCardPreview( + // Annual Plan - Selected with badge and savings + PurchaseOptionCardDemo( title: "Pro Annual", description: "Annual subscription • Auto-renewable", price: "$99.99", billingPeriod: "Yearly", badge: "Most Popular", features: ["Cloud sync", "Premium filters", "Priority support"], - savings: "Save 15%", + savings: "Save 30%", isSelected: true ) - // Lifetime Purchase - PurchaseOptionCardPreview( + // Lifetime Plan - Unselected with badge + PurchaseOptionCardDemo( title: "Pro Lifetime", description: "One-time purchase • Lifetime access", price: "$199.99", @@ -262,14 +277,21 @@ struct PurchaseOptionCard: View { ) } } + + Text("Note: Uses same visual structure as real PurchaseOptionCard") + .font(.caption2) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.top) } .padding() .background(Color(NSColor.windowBackgroundColor)) } -// MARK: - Preview Helper +// MARK: - Preview Demo Component +// This replicates the exact visual structure of PurchaseOptionCard for previewing -private struct PurchaseOptionCardPreview: View { +private struct PurchaseOptionCardDemo: View { let title: String let description: String let price: String @@ -280,9 +302,9 @@ private struct PurchaseOptionCardPreview: View { let isSelected: Bool var body: some View { - Button(action: { }) { + Button(action: { print("Selected: \(title)") }) { HStack(spacing: 16) { - // Selection indicator + // Selection indicator (same as real component) ZStack { Circle() .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: 2) @@ -299,6 +321,7 @@ private struct PurchaseOptionCardPreview: View { } } + // Content (same structure as real component) VStack(alignment: .leading, spacing: 4) { HStack { Text(title) @@ -343,6 +366,7 @@ private struct PurchaseOptionCardPreview: View { Spacer() + // Pricing (same structure as real component) VStack(alignment: .trailing, spacing: 2) { Text(price) .font(.system(size: 18, weight: .bold)) From 50673705e25ae178bec6503b7aadc1d190411cec Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 20:08:37 +0800 Subject: [PATCH 4/7] refactor(ui): extract PurchaseOptionCard UI into reusable component Introduce shared PurchaseOptionCardView for cleaner separation and styling. Consolidate period text formatting with style variants and apply consistent design constants. Simplify preview by using the new shared component and remove duplicate demo code. --- .../UI/Components/PurchaseOptionCard.swift | 410 ++++++++---------- 1 file changed, 188 insertions(+), 222 deletions(-) diff --git a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift index 488c23f..8d87def 100644 --- a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift +++ b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift @@ -8,6 +8,40 @@ import SwiftUI import StoreKit +// MARK: - Styling Constants + +private enum CardStyle { + static let cornerRadius: CGFloat = 14 + static let horizontalPadding: CGFloat = 20 + static let verticalPadding: CGFloat = 16 + static let contentSpacing: CGFloat = 16 + static let contentVerticalSpacing: CGFloat = 4 + static let featuresSpacing: CGFloat = 2 + static let featuresTopPadding: CGFloat = 2 + + static let selectionIndicatorSize: CGFloat = 20 + static let selectionIndicatorFillSize: CGFloat = 10 + static let selectionIndicatorStroke: CGFloat = 2 + static let selectedStrokeWidth: CGFloat = 2 + static let unselectedStrokeWidth: CGFloat = 1 + + static let selectedScale: CGFloat = 1.02 + static let animationDuration: Double = 0.15 + + // Font sizes + static let titleFontSize: CGFloat = 16 + static let descriptionFontSize: CGFloat = 13 + static let priceFontSize: CGFloat = 18 + static let billingPeriodFontSize: CGFloat = 12 + static let badgeFontSize: CGFloat = 10 + static let savingsFontSize: CGFloat = 10 + static let featureFontSize: CGFloat = 11 + + // Badge styling + static let badgeHorizontalPadding: CGFloat = 8 + static let badgeVerticalPadding: CGFloat = 2 +} + struct PurchaseOptionCard: View { let product: Product let isSelected: Bool @@ -53,13 +87,13 @@ struct PurchaseOptionCard: View { // Add trial info if available if let intro = subscription.introductoryOffer, intro.paymentMode == .freeTrial { - let trialLength = periodText(intro.period) + let trialLength = periodText(intro.period, style: .descriptive) description += "\(trialLength) free trial • " } // Add subscription period let period = subscription.subscriptionPeriod - description += "\(periodDescription(period)) subscription" + description += "\(periodText(period, style: .billing)) subscription" return description } @@ -77,7 +111,7 @@ struct PurchaseOptionCard: View { switch product.type { case .autoRenewable: if let subscription = product.subscription { - return periodText(subscription.subscriptionPeriod) + return periodText(subscription.subscriptionPeriod, style: .billing) } return "Subscription" case .nonConsumable: @@ -89,209 +123,63 @@ struct PurchaseOptionCard: View { } } - private func periodDescription(_ period: Product.SubscriptionPeriod) -> String { + private func periodText(_ period: Product.SubscriptionPeriod, style: PeriodTextStyle = .billing) -> String { let unit = period.unit let value = period.value - switch unit { - case .day: - return value == 1 ? "Daily" : "\(value)-day" - case .week: - return value == 1 ? "Weekly" : "\(value)-week" - case .month: - return value == 1 ? "Monthly" : "\(value)-month" - case .year: - return value == 1 ? "Annual" : "\(value)-year" - @unknown default: - return "Periodic" + switch style { + case .billing: + switch unit { + case .day: + return value == 1 ? "Daily" : "\(value)-day" + case .week: + return value == 1 ? "Weekly" : "\(value)-week" + case .month: + return value == 1 ? "Monthly" : "\(value)-month" + case .year: + return value == 1 ? "Annual" : "\(value)-year" + @unknown default: + return "Periodic" + } + case .descriptive: + switch unit { + case .day: + return value == 1 ? "Daily" : "Every \(value) days" + case .week: + return value == 1 ? "Weekly" : "Every \(value) weeks" + case .month: + return value == 1 ? "Monthly" : "Every \(value) months" + case .year: + return value == 1 ? "Yearly" : "Every \(value) years" + @unknown default: + return "Periodic" + } } } - private func periodText(_ period: Product.SubscriptionPeriod) -> String { - let unit = period.unit - let value = period.value - - switch unit { - case .day: - return value == 1 ? "Daily" : "Every \(value) days" - case .week: - return value == 1 ? "Weekly" : "Every \(value) weeks" - case .month: - return value == 1 ? "Monthly" : "Every \(value) months" - case .year: - return value == 1 ? "Yearly" : "Every \(value) years" - @unknown default: - return "Periodic" - } + private enum PeriodTextStyle { + case billing // "Monthly", "Annual" + case descriptive // "Every month", "Every year" } var body: some View { - Button(action: onSelect) { - HStack(spacing: 16) { - // Enhanced selection indicator - ZStack { - Circle() - .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: 2) - .frame(width: 20, height: 20) - .background( - Circle() - .fill(isSelected ? Color.blue.opacity(0.1) : Color.clear) - ) - - if isSelected { - Circle() - .fill(Color.blue) - .frame(width: 10, height: 10) - } - } - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(product.displayName) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) - - if let badge = badge { - Text(badge) - .font(.system(size: 10, weight: .bold)) - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background( - Capsule() - .fill(badge.lowercased().contains("popular") ? Color.orange : Color.blue) - ) - } - - Spacer() - } - - Text(productDescription) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - - // Show key features if provided - if let features = features, !features.isEmpty { - VStack(alignment: .leading, spacing: 2) { - ForEach(features.prefix(2), id: \.self) { feature in - HStack(spacing: 4) { - Text("•") - .foregroundColor(.secondary) - .font(.system(size: 11)) - Text(feature) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - } - .padding(.top, 2) - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(product.displayPrice) - .font(.system(size: 18, weight: .bold)) - .foregroundColor(.primary) - - if let savings = savings { - Text(savings) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.green) - } - - Text(billingPeriod) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(isSelected ? Color.blue.opacity(0.06) : Color(NSColor.controlBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(isSelected ? Color.blue.opacity(0.4) : Color.gray.opacity(0.15), lineWidth: isSelected ? 2 : 1) - ) - ) - } - .buttonStyle(PlainButtonStyle()) - .scaleEffect(isSelected ? 1.02 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isSelected) - } -} - -// MARK: - Preview - -#if DEBUG -#Preview("Purchase Option Cards") { - VStack(spacing: 16) { - Text("Purchase Option Cards") - .font(.title2.bold()) - .padding(.bottom) - - Text("Real PurchaseOptionCard Components") - .font(.caption) - .foregroundColor(.secondary) - .padding(.bottom, 8) - - VStack(spacing: 12) { - // Visual representation showing the real component structure - Group { - // Monthly Plan - Unselected - PurchaseOptionCardDemo( - title: "Pro Monthly", - description: "7 days free trial • Monthly subscription", - price: "$9.99", - billingPeriod: "Monthly", - badge: nil, - features: ["Cloud sync", "Premium filters"], - savings: nil, - isSelected: false - ) - - // Annual Plan - Selected with badge and savings - PurchaseOptionCardDemo( - title: "Pro Annual", - description: "Annual subscription • Auto-renewable", - price: "$99.99", - billingPeriod: "Yearly", - badge: "Most Popular", - features: ["Cloud sync", "Premium filters", "Priority support"], - savings: "Save 30%", - isSelected: true - ) - - // Lifetime Plan - Unselected with badge - PurchaseOptionCardDemo( - title: "Pro Lifetime", - description: "One-time purchase • Lifetime access", - price: "$199.99", - billingPeriod: "Lifetime", - badge: "Best Value", - features: ["All features included", "Lifetime updates"], - savings: nil, - isSelected: false - ) - } - } - - Text("Note: Uses same visual structure as real PurchaseOptionCard") - .font(.caption2) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.top) + PurchaseOptionCardView( + title: product.displayName, + description: productDescription, + price: product.displayPrice, + billingPeriod: billingPeriod, + badge: badge, + features: features, + savings: savings, + isSelected: isSelected, + onSelect: onSelect + ) } - .padding() - .background(Color(NSColor.windowBackgroundColor)) } -// MARK: - Preview Demo Component -// This replicates the exact visual structure of PurchaseOptionCard for previewing +// MARK: - Shared UI Component -private struct PurchaseOptionCardDemo: View { +private struct PurchaseOptionCardView: View { let title: String let description: String let price: String @@ -300,15 +188,16 @@ private struct PurchaseOptionCardDemo: View { let features: [String]? let savings: String? let isSelected: Bool + let onSelect: () -> Void var body: some View { - Button(action: { print("Selected: \(title)") }) { - HStack(spacing: 16) { - // Selection indicator (same as real component) + Button(action: onSelect) { + HStack(spacing: CardStyle.contentSpacing) { + // Selection indicator ZStack { Circle() - .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: 2) - .frame(width: 20, height: 20) + .stroke(isSelected ? Color.blue : Color.gray.opacity(0.3), lineWidth: CardStyle.selectionIndicatorStroke) + .frame(width: CardStyle.selectionIndicatorSize, height: CardStyle.selectionIndicatorSize) .background( Circle() .fill(isSelected ? Color.blue.opacity(0.1) : Color.clear) @@ -317,23 +206,22 @@ private struct PurchaseOptionCardDemo: View { if isSelected { Circle() .fill(Color.blue) - .frame(width: 10, height: 10) + .frame(width: CardStyle.selectionIndicatorFillSize, height: CardStyle.selectionIndicatorFillSize) } } - // Content (same structure as real component) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: CardStyle.contentVerticalSpacing) { HStack { Text(title) - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: CardStyle.titleFontSize, weight: .semibold)) .foregroundColor(.primary) if let badge = badge { Text(badge) - .font(.system(size: 10, weight: .bold)) + .font(.system(size: CardStyle.badgeFontSize, weight: .bold)) .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) + .padding(.horizontal, CardStyle.badgeHorizontalPadding) + .padding(.vertical, CardStyle.badgeVerticalPadding) .background( Capsule() .fill(badge.lowercased().contains("popular") ? Color.orange : Color.blue) @@ -344,59 +232,137 @@ private struct PurchaseOptionCardDemo: View { } Text(description) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: CardStyle.descriptionFontSize, weight: .medium)) .foregroundColor(.secondary) if let features = features, !features.isEmpty { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: CardStyle.featuresSpacing) { ForEach(features.prefix(2), id: \.self) { feature in HStack(spacing: 4) { Text("•") .foregroundColor(.secondary) - .font(.system(size: 11)) + .font(.system(size: CardStyle.featureFontSize)) Text(feature) - .font(.system(size: 11)) + .font(.system(size: CardStyle.featureFontSize)) .foregroundColor(.secondary) } } } - .padding(.top, 2) + .padding(.top, CardStyle.featuresTopPadding) } } Spacer() - // Pricing (same structure as real component) - VStack(alignment: .trailing, spacing: 2) { + VStack(alignment: .trailing, spacing: CardStyle.featuresSpacing) { Text(price) - .font(.system(size: 18, weight: .bold)) + .font(.system(size: CardStyle.priceFontSize, weight: .bold)) .foregroundColor(.primary) if let savings = savings { Text(savings) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: CardStyle.savingsFontSize, weight: .semibold)) .foregroundColor(.green) } Text(billingPeriod) - .font(.system(size: 12, weight: .medium)) + .font(.system(size: CardStyle.billingPeriodFontSize, weight: .medium)) .foregroundColor(.secondary) } } - .padding(.horizontal, 20) - .padding(.vertical, 16) + .padding(.horizontal, CardStyle.horizontalPadding) + .padding(.vertical, CardStyle.verticalPadding) .background( - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: CardStyle.cornerRadius) .fill(isSelected ? Color.blue.opacity(0.06) : Color(NSColor.controlBackgroundColor)) .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(isSelected ? Color.blue.opacity(0.4) : Color.gray.opacity(0.15), lineWidth: isSelected ? 2 : 1) + RoundedRectangle(cornerRadius: CardStyle.cornerRadius) + .stroke(isSelected ? Color.blue.opacity(0.4) : Color.gray.opacity(0.15), + lineWidth: isSelected ? CardStyle.selectedStrokeWidth : CardStyle.unselectedStrokeWidth) ) ) } .buttonStyle(PlainButtonStyle()) - .scaleEffect(isSelected ? 1.02 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isSelected) + .scaleEffect(isSelected ? CardStyle.selectedScale : 1.0) + .animation(.easeInOut(duration: CardStyle.animationDuration), value: isSelected) + } +} + +// MARK: - Preview + +#if DEBUG +#Preview("Purchase Option Cards") { + VStack(spacing: 20) { + VStack(spacing: 8) { + Text("PurchaseOptionCard Preview") + .font(.title2.bold()) + + Text("Different states and configurations") + .font(.caption) + .foregroundColor(.secondary) + } + + VStack(spacing: 12) { + // Standard monthly subscription with trial + PurchaseOptionCardView( + title: "Pro Monthly", + description: "7 days free trial • Monthly subscription", + price: "$9.99", + billingPeriod: "Monthly", + badge: nil, + features: ["Cloud sync", "Premium filters"], + savings: nil, + isSelected: false, + onSelect: { print("Selected: Pro Monthly") } + ) + + // Popular annual plan with savings + PurchaseOptionCardView( + title: "Pro Annual", + description: "Annual subscription • Auto-renewable", + price: "$99.99", + billingPeriod: "Yearly", + badge: "Most Popular", + features: ["Cloud sync", "Premium filters", "Priority support"], + savings: "Save 30%", + isSelected: true, + onSelect: { print("Selected: Pro Annual") } + ) + + // Lifetime purchase option + PurchaseOptionCardView( + title: "Pro Lifetime", + description: "One-time purchase • Lifetime access", + price: "$199.99", + billingPeriod: "Lifetime", + badge: "Best Value", + features: ["All features included", "Lifetime updates"], + savings: nil, + isSelected: false, + onSelect: { print("Selected: Pro Lifetime") } + ) + } + + VStack(spacing: 4) { + Text("Features Demonstrated:") + .font(.caption.bold()) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text("• Selection states (selected/unselected)") + Text("• Marketing badges (Most Popular, Best Value)") + Text("• Savings indicators (Save 30%)") + Text("• Feature lists with bullet points") + Text("• Different product types (subscription, lifetime)") + .font(.caption2) + .foregroundColor(.secondary) + } + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.top, 8) } + .padding() + .background(Color(NSColor.windowBackgroundColor)) } #endif From 401ea9541065ebbeb05e226cd69b6846c4796f9b Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 20:21:57 +0800 Subject: [PATCH 5/7] refactor(configuration): simplify StoreKitConfiguration setup using initialize Replace manual feature registration and product loading with a single call to InAppKit.initialize to streamline setup. --- .../Configuration/StoreKitConfiguration.swift | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift index 99fa032..22f0c0b 100644 --- a/Sources/InAppKit/Configuration/StoreKitConfiguration.swift +++ b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift @@ -85,18 +85,8 @@ public class StoreKitConfiguration { // MARK: - Internal Setup internal func setup() async { - let productIds = productConfigs.map { $0.id } - - // Register features - for config in productConfigs { - for feature in config.features { - InAppKit.shared.registerFeature(feature, productIds: [config.id]) - } - } - - // Load products - await InAppKit.shared.loadProducts(productIds: productIds) - InAppKit.shared.isInitialized = true + // Use the existing InAppKit.initialize method which handles both features and marketing info + await InAppKit.shared.initialize(with: productConfigs) } } From 0c731b9a16b2815bd530a07768abfb326faae0b5 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 20:38:43 +0800 Subject: [PATCH 6/7] docs(readme): add comprehensive guide for advanced feature setup Add detailed documentation with examples for type-safe and flexible feature definitions, product marketing enhancements, feature gating in UI, custom paywall implementation, and subscription status display. --- README.md | 506 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 467 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6608ef1..976497b 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Button("Premium Feature") { doPremiumThing() } 📋 Define specific features ```swift -enum AppFeature: String, InAppKit.AppFeature { +enum AppFeature: String, AppFeature { case removeAds = "remove_ads" case cloudSync = "cloud_sync" case exportPDF = "export_pdf" @@ -131,7 +131,7 @@ enum AppFeature: String, InAppKit.AppFeature { ContentView() .withPurchases(products: [ - Product("com.yourapp.pro", AppFeature.allCases) + Product("com.yourapp.pro", features: AppFeature.allCases) ]) ``` @@ -549,6 +549,192 @@ let features = InAppKit.shared.marketingFeatures(for: "com.app.pro") let savings = InAppKit.shared.savings(for: "com.app.pro") ``` +### 🎯 Advanced Feature Configuration + +InAppKit supports two approaches for defining features, giving you flexibility based on your app's complexity: + +#### **Approach 1: Type-Safe AppFeature Protocol (Recommended)** + +Define features using the `AppFeature` protocol for type safety and better developer experience: + +```swift +import InAppKit + +// Define your app's features +enum AppFeature: String, AppFeature, CaseIterable { + case cloudSync = "cloud_sync" + case advancedFilters = "advanced_filters" + case exportPDF = "export_pdf" + case prioritySupport = "priority_support" + case teamCollaboration = "team_collaboration" +} + +// Configure products with type-safe features +ContentView() + .withPurchases(products: [ + // Basic Plan + Product("com.yourapp.basic", features: [AppFeature.cloudSync]) + .withMarketingFeatures(["Cloud sync across devices"]), + + // Pro Plan + Product("com.yourapp.pro", features: [AppFeature.cloudSync, AppFeature.advancedFilters, AppFeature.exportPDF]) + .withBadge("Most Popular") + .withMarketingFeatures(["Advanced filters", "PDF export", "Priority support"]) + .withSavings("Save 25%"), + + // Premium Plan + Product("com.yourapp.premium", features: AppFeature.allCases) + .withBadge("Best Value") + .withMarketingFeatures(["All features included", "Team collaboration"]) + ]) +``` + +**Benefits of AppFeature Protocol:** +- ✅ **Type Safety**: Compile-time checks prevent typos +- ✅ **Autocomplete**: IDE provides feature suggestions +- ✅ **Refactoring**: Easy to rename features across codebase +- ✅ **Documentation**: Self-documenting feature names + +#### **Approach 2: Flexible Hashable Features** + +Use any `Hashable` type for maximum flexibility: + +```swift +// Use String literals (simple but less safe) +Product("com.yourapp.pro", features: ["sync", "export", "filters"]) + +// Use custom types +struct Feature: Hashable { + let name: String + let category: String +} + +Product("com.yourapp.pro", features: [ + Feature(name: "sync", category: "storage"), + Feature(name: "export", category: "sharing") +]) + +// Mix and match different types +Product("com.yourapp.pro", features: ["basic_sync", 42, AppFeature.cloudSync]) +``` + +#### **Feature Usage in UI** + +Both approaches work seamlessly with InAppKit's gating system: + +```swift +// Type-safe approach (recommended) +Button("Sync to Cloud") { syncToCloud() } + .requiresPurchase(AppFeature.cloudSync) + +Button("Export as PDF") { exportPDF() } + .requiresPurchase(AppFeature.exportPDF) + +// Flexible approach +Button("Advanced Feature") { useAdvancedFeature() } + .requiresPurchase("advanced_feature") + +// Conditional gating +Button("Team Collaboration") { openTeamPanel() } + .requiresPurchase(AppFeature.teamCollaboration, when: isTeamMember) +``` + +#### **Runtime Feature Management** + +Access and manage features programmatically: + +```swift +// Check feature access +if InAppKit.shared.hasAccess(to: AppFeature.cloudSync) { + enableCloudSync() +} + +// Register features manually (usually automatic) +InAppKit.shared.registerFeature(AppFeature.cloudSync, productIds: ["com.app.pro"]) + +// Check feature registration +if InAppKit.shared.isFeatureRegistered(AppFeature.exportPDF) { + showExportButton() +} + +// Get products that provide a feature +let syncProducts = InAppKit.shared.products(for: AppFeature.cloudSync) +``` + +#### **Complex Feature Hierarchies** + +For apps with complex feature sets, organize features into logical groups: + +```swift +enum CreativeFeature: String, AppFeature, CaseIterable { + // Export Features + case exportHD = "export_hd" + case exportRAW = "export_raw" + case batchExport = "batch_export" + + // Filter Features + case basicFilters = "basic_filters" + case aiFilters = "ai_filters" + case customFilters = "custom_filters" + + // Storage Features + case cloudStorage = "cloud_storage" + case unlimitedStorage = "unlimited_storage" + case versionHistory = "version_history" + + // Collaboration Features + case sharing = "sharing" + case teamWorkspace = "team_workspace" + case realTimeCollab = "realtime_collab" +} + +// Organize products by user personas +ContentView() + .withPurchases(products: [ + // Hobbyist + Product("com.creativeapp.hobbyist", features: [ + .basicFilters, .exportHD, .cloudStorage + ]), + + // Professional + Product("com.creativeapp.pro", features: [ + .basicFilters, .aiFilters, .exportHD, .exportRAW, + .batchExport, .cloudStorage, .sharing + ]), + + // Studio/Team + Product("com.creativeapp.studio", features: CreativeFeature.allCases) + ]) +``` + +#### **Best Practices** + +1. **Use Descriptive Names**: `cloudSync` not `sync` +2. **Group Related Features**: Use enum cases that make logical sense +3. **Consider User Mental Models**: Features should match how users think about functionality +4. **Plan for Growth**: Design your feature enum to accommodate future additions +5. **Document Feature Purpose**: Add comments explaining what each feature unlocks + +```swift +enum AppFeature: String, AppFeature, CaseIterable { + // Storage & Sync + case cloudSync = "cloud_sync" // Sync data across devices + case unlimitedStorage = "unlimited" // Remove storage limits + + // Content Creation + case advancedTools = "advanced_tools" // Professional editing tools + case batchProcessing = "batch" // Process multiple items + + // Sharing & Collaboration + case shareLinks = "share_links" // Generate shareable links + case teamWorkspace = "team_workspace" // Multi-user collaboration + + // Support & Service + case prioritySupport = "priority" // Fast customer support + case earlyAccess = "early_access" // Beta features access +} +``` + ### Custom Premium Modifiers Create your own premium gating logic: @@ -723,98 +909,340 @@ ContentView() .requiresPurchase(AppFeature.premiumContent) ``` -### Complete Implementation: Photo Editing App +### 📋 Complete Implementation Guide: Photo Editing App + +Here's a step-by-step implementation that shows how to use InAppKit's advanced features in a real app: + +#### **Step 1: Define Your App Features** ```swift import SwiftUI import InAppKit // Define app features aligned with business tiers -enum AppFeature: String, InAppKit.AppFeature { - // Basic tier features +enum AppFeature: String, AppFeature, CaseIterable { + // Basic tier features (always free) case basicFilters = "basic_filters" case cropResize = "crop_resize" - + // Pro tier features case advancedFilters = "advanced_filters" case batchProcessing = "batch_processing" case cloudStorage = "cloud_storage" - + // Professional tier features case rawSupport = "raw_support" case teamCollaboration = "team_collaboration" case prioritySupport = "priority_support" - + // Enterprise tier features case apiAccess = "api_access" case whiteLabeling = "white_labeling" case ssoIntegration = "sso_integration" } +``` +#### **Step 2: Configure Products with Marketing Enhancement** + +```swift @main struct PhotoEditApp: App { var body: some Scene { WindowGroup { ContentView() .withPurchases(products: [ - // Freemium: Basic features included free - Product("com.photoapp.pro", [ - AppFeature.advancedFilters, - AppFeature.batchProcessing, + // Pro Plan - Individual users + Product("com.photoapp.pro", features: [ + AppFeature.advancedFilters, + AppFeature.batchProcessing, AppFeature.cloudStorage - ]), - Product("com.photoapp.professional", [ - AppFeature.advancedFilters, - AppFeature.batchProcessing, + ]) + .withBadge("Most Popular") + .withMarketingFeatures([ + "AI-powered filters", + "Batch processing", + "Cloud storage" + ]) + .withSavings("Save 30%"), + + // Professional Plan - Power users + Product("com.photoapp.professional", features: [ + AppFeature.advancedFilters, + AppFeature.batchProcessing, AppFeature.cloudStorage, - AppFeature.rawSupport, - AppFeature.teamCollaboration, + AppFeature.rawSupport, + AppFeature.teamCollaboration, AppFeature.prioritySupport + ]) + .withBadge("Pro Choice") + .withMarketingFeatures([ + "RAW file support", + "Team collaboration", + "Priority support" ]), - Product("com.photoapp.enterprise", AppFeature.allCases) + + // Enterprise Plan - Teams & organizations + Product("com.photoapp.enterprise", features: AppFeature.allCases) + .withBadge("Best Value") + .withMarketingFeatures([ + "All features included", + "API access", + "White-label options" + ]) ]) .withPaywall { context in - PhotoAppPaywallView( - triggeredBy: context.triggeredBy, - products: context.availableProducts - ) + PhotoAppPaywallView(context: context) } } } } +``` + +#### **Step 3: Implement Feature Gating in UI** +```swift struct ContentView: View { @State private var imageCount = 1 @State private var isTeamMember = false - + @State private var selectedImages: [UIImage] = [] + var body: some View { VStack(spacing: 20) { + Text("Photo Editor Pro") + .font(.largeTitle.bold()) + // Always free - basic features - Button("Apply Basic Filter") { applyBasicFilter() } - Button("Crop & Resize") { cropAndResize() } - - // Pro tier gating - Button("Advanced AI Filter") { applyAIFilter() } + Group { + Button("Apply Basic Filter") { + applyBasicFilter() + } + .buttonStyle(.borderedProminent) + + Button("Crop & Resize") { + cropAndResize() + } + .buttonStyle(.bordered) + } + + Divider() + + // Pro tier gating - shows paywall if not purchased + Group { + Button("Advanced AI Filter") { + applyAIFilter() + } .requiresPurchase(AppFeature.advancedFilters) - - Button("Batch Process") { batchProcess() } - .requiresPurchase(AppFeature.batchProcessing, when: imageCount > 5) - + + Button("Batch Process \(selectedImages.count) Images") { + batchProcess() + } + .requiresPurchase(AppFeature.batchProcessing, when: selectedImages.count > 5) + + Button("Save to Cloud") { + saveToCloud() + } + .requiresPurchase(AppFeature.cloudStorage) + } + + Divider() + // Professional tier gating - Button("Edit RAW Files") { editRAW() } + Group { + Button("Edit RAW Files") { + editRAW() + } .requiresPurchase(AppFeature.rawSupport) - - Button("Team Collaboration") { openTeamPanel() } + + Button("Team Collaboration") { + openTeamPanel() + } .requiresPurchase(AppFeature.teamCollaboration, when: isTeamMember) - + } + + Divider() + // Enterprise tier gating - Button("API Access") { configureAPI() } - .requiresPurchase(AppFeature.apiAccess) + Button("Configure API Access") { + configureAPI() + } + .requiresPurchase(AppFeature.apiAccess) + + Spacer() + + // Show current subscription status + SubscriptionStatusView() + } + .padding() + } + + // MARK: - Feature Implementation + + private func applyBasicFilter() { + // Always available + print("Applied basic filter") + } + + private func cropAndResize() { + // Always available + print("Cropped and resized image") + } + + private func applyAIFilter() { + // Requires AppFeature.advancedFilters + print("Applied AI-powered filter") + } + + private func batchProcess() { + // Requires AppFeature.batchProcessing when > 5 images + print("Batch processing \(selectedImages.count) images") + } + + private func saveToCloud() { + // Requires AppFeature.cloudStorage + print("Saved to cloud storage") + } + + private func editRAW() { + // Requires AppFeature.rawSupport + print("Opened RAW editor") + } + + private func openTeamPanel() { + // Requires AppFeature.teamCollaboration + print("Opened team collaboration panel") + } + + private func configureAPI() { + // Requires AppFeature.apiAccess + print("Opened API configuration") + } +} +``` + +#### **Step 4: Custom Paywall (Optional)** + +```swift +struct PhotoAppPaywallView: View { + let context: PaywallContext + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 12) { + Image(systemName: "camera.aperture") + .font(.system(size: 60)) + .foregroundColor(.blue) + + Text("Unlock Professional Photo Editing") + .font(.title.bold()) + .multilineTextAlignment(.center) + + if let triggeredBy = context.triggeredBy { + Text("To use \(triggeredBy), upgrade to Pro") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // Products + VStack(spacing: 12) { + ForEach(context.availableProducts, id: \.self) { product in + PurchaseOptionCard( + product: product, + isSelected: product == context.recommendedProduct, + onSelect: { + Task { + try await InAppKit.shared.purchase(product) + dismiss() + } + }, + badge: InAppKit.shared.badge(for: product.id), + features: InAppKit.shared.marketingFeatures(for: product.id), + savings: InAppKit.shared.savings(for: product.id) + ) + } + } + + // Actions + Button("Restore Purchases") { + Task { + await InAppKit.shared.restorePurchases() + if InAppKit.shared.hasAnyPurchase { + dismiss() + } + } + } + .foregroundColor(.blue) } + .padding() } } ``` +#### **Step 5: Subscription Status Display** + +```swift +struct SubscriptionStatusView: View { + @State private var inAppKit = InAppKit.shared + + var body: some View { + VStack(spacing: 8) { + if inAppKit.hasAnyPurchase { + Label("Pro Features Unlocked", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.headline) + + // Show specific features user has access to + VStack(alignment: .leading, spacing: 4) { + if inAppKit.hasAccess(to: AppFeature.advancedFilters) { + Text("• Advanced AI Filters") + } + if inAppKit.hasAccess(to: AppFeature.cloudStorage) { + Text("• Cloud Storage") + } + if inAppKit.hasAccess(to: AppFeature.rawSupport) { + Text("• RAW File Support") + } + } + .font(.caption) + .foregroundColor(.secondary) + } else { + Label("Free Version", systemImage: "person.circle") + .foregroundColor(.orange) + .font(.headline) + + Text("Upgrade to unlock all features") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} +``` + +#### **What This Implementation Demonstrates:** + +- ✅ **Type-safe feature definitions** with `AppFeature` enum +- ✅ **Marketing-enhanced products** with badges, features, and savings +- ✅ **Conditional feature gating** based on usage patterns +- ✅ **Professional paywall integration** with context awareness +- ✅ **Real-time subscription status** display +- ✅ **Graceful feature degradation** for free users + +#### **Expected User Experience:** + +1. **Free users** can use basic filters and crop/resize +2. **When they try advanced features**, they see a contextual paywall +3. **After purchase**, all features unlock immediately +4. **Premium badges** appear on unlocked features +5. **Subscription status** is clearly displayed + +This implementation follows InAppKit's design principles while providing a professional user experience that converts free users to paid subscribers. + ## 🤝 Contributing We welcome contributions! Here's how to get started: From 6ada4561f394d24f45efbfabbae7afe4e6d20636 Mon Sep 17 00:00:00 2001 From: slamhan Date: Sun, 21 Sep 2025 20:49:26 +0800 Subject: [PATCH 7/7] feat(paywall): add marketing info to PaywallContext and examples Enhance PaywallContext with badge, features, and savings accessors to support marketing data. Update README with usage examples showing how to build custom paywalls using these marketing helpers. Add tests verifying PaywallContext marketing helpers and productsWithMarketing property. --- README.md | 113 +++++++++++++++--- .../Configuration/ProductConfiguration.swift | 43 ++++++- Tests/InAppKitTests/InAppKitTests.swift | 24 +++- 3 files changed, 158 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 976497b..84ba309 100644 --- a/README.md +++ b/README.md @@ -289,42 +289,100 @@ ContentView() .withPurchases(products: products) ``` -### Custom Paywall +### Custom Paywall with Marketing Information -Create your own paywall with full context information: +Create your own paywall with full context and marketing information: ```swift -// Context-aware paywall with fluent API +// Enhanced paywall with marketing data from context ContentView() .withPurchases(products: products) .withPaywall { context in VStack { Text("Upgrade to unlock \(context.triggeredBy ?? "premium features")") - + + // Simple approach - access marketing info directly ForEach(context.availableProducts, id: \.self) { product in - Button(product.displayName) { - Task { - try await InAppKit.shared.purchase(product) + VStack(alignment: .leading) { + HStack { + Text(product.displayName) + + // Badge from context + if let badge = context.badge(for: product) { + Text(badge) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(4) + } + + Spacer() + + VStack(alignment: .trailing) { + Text(product.displayPrice) + + // Savings from context + if let savings = context.savings(for: product) { + Text(savings) + .foregroundColor(.green) + .font(.caption) + } + } + } + + // Marketing features from context + if let features = context.marketingFeatures(for: product) { + VStack(alignment: .leading) { + ForEach(features, id: \.self) { feature in + Text("• \(feature)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + Button("Purchase") { + Task { + try await InAppKit.shared.purchase(product) + } } } + .padding() + .border(Color.gray) } - - if let recommended = context.recommendedProduct { - Text("Recommended: \(recommended.displayName)") + + // Advanced approach - use productsWithMarketing + ForEach(context.productsWithMarketing, id: \.product) { item in + ProductCard( + product: item.product, + badge: item.badge, + features: item.features, + savings: item.savings + ) } } } ``` -### Paywall Context +### Enhanced Paywall Context + +The `PaywallContext` provides rich information about the paywall trigger and easy access to marketing data: -The `PaywallContext` provides rich information about how the paywall was triggered: +> **Note**: Marketing information methods are `@MainActor` isolated since they access InAppKit's shared state. This is perfect for SwiftUI views which run on the main actor by default. ```swift public struct PaywallContext { public let triggeredBy: String? // What action triggered this - public let availableProducts: [StoreKit.Product] // Products that can be purchased + public let availableProducts: [StoreKit.Product] // Products that can be purchased public let recommendedProduct: StoreKit.Product? // Best product recommendation + + // Marketing Information Helpers (Main Actor) + @MainActor func badge(for product: StoreKit.Product) -> String? + @MainActor func marketingFeatures(for product: StoreKit.Product) -> [String]? + @MainActor func savings(for product: StoreKit.Product) -> String? + @MainActor func marketingInfo(for product: StoreKit.Product) -> (badge: String?, features: [String]?, savings: String?) + @MainActor var productsWithMarketing: [(product: StoreKit.Product, badge: String?, features: [String]?, savings: String?)] } ``` @@ -1145,7 +1203,7 @@ struct PhotoAppPaywallView: View { } } - // Products + // Products - using enhanced PaywallContext VStack(spacing: 12) { ForEach(context.availableProducts, id: \.self) { product in PurchaseOptionCard( @@ -1157,12 +1215,33 @@ struct PhotoAppPaywallView: View { dismiss() } }, - badge: InAppKit.shared.badge(for: product.id), - features: InAppKit.shared.marketingFeatures(for: product.id), - savings: InAppKit.shared.savings(for: product.id) + badge: context.badge(for: product), // ✨ From context + features: context.marketingFeatures(for: product), // ✨ From context + savings: context.savings(for: product) // ✨ From context + ) + } + } + + // Alternative: Use the convenience property + /* + VStack(spacing: 12) { + ForEach(context.productsWithMarketing, id: \.product) { item in + PurchaseOptionCard( + product: item.product, + isSelected: item.product == context.recommendedProduct, + onSelect: { + Task { + try await InAppKit.shared.purchase(item.product) + dismiss() + } + }, + badge: item.badge, + features: item.features, + savings: item.savings ) } } + */ // Actions Button("Restore Purchases") { diff --git a/Sources/InAppKit/Configuration/ProductConfiguration.swift b/Sources/InAppKit/Configuration/ProductConfiguration.swift index 61a5689..055a04d 100644 --- a/Sources/InAppKit/Configuration/ProductConfiguration.swift +++ b/Sources/InAppKit/Configuration/ProductConfiguration.swift @@ -134,15 +134,54 @@ public extension ProductConfig { // MARK: - PaywallContext -/// Context for product-based paywalls +/// Context for product-based paywalls with marketing information public struct PaywallContext { public let triggeredBy: String? // What action triggered the paywall public let availableProducts: [StoreKit.Product] // Products that can be purchased public let recommendedProduct: StoreKit.Product? // Best product to recommend - + public init(triggeredBy: String? = nil, availableProducts: [StoreKit.Product] = [], recommendedProduct: StoreKit.Product? = nil) { self.triggeredBy = triggeredBy self.availableProducts = availableProducts self.recommendedProduct = recommendedProduct ?? availableProducts.first } + + // MARK: - Marketing Information Helpers + + /// Get marketing badge for a product + @MainActor + public func badge(for product: StoreKit.Product) -> String? { + return InAppKit.shared.badge(for: product.id) + } + + /// Get marketing features for a product + @MainActor + public func marketingFeatures(for product: StoreKit.Product) -> [String]? { + return InAppKit.shared.marketingFeatures(for: product.id) + } + + /// Get savings information for a product + @MainActor + public func savings(for product: StoreKit.Product) -> String? { + return InAppKit.shared.savings(for: product.id) + } + + /// Get all marketing information for a product + @MainActor + public func marketingInfo(for product: StoreKit.Product) -> (badge: String?, features: [String]?, savings: String?) { + return ( + badge: badge(for: product), + features: marketingFeatures(for: product), + savings: savings(for: product) + ) + } + + /// Get products with their marketing information + @MainActor + public var productsWithMarketing: [(product: StoreKit.Product, badge: String?, features: [String]?, savings: String?)] { + return availableProducts.map { product in + let info = marketingInfo(for: product) + return (product: product, badge: info.badge, features: info.features, savings: info.savings) + } + } } diff --git a/Tests/InAppKitTests/InAppKitTests.swift b/Tests/InAppKitTests/InAppKitTests.swift index eef4f4d..f2189cd 100644 --- a/Tests/InAppKitTests/InAppKitTests.swift +++ b/Tests/InAppKitTests/InAppKitTests.swift @@ -52,21 +52,39 @@ struct InAppKitTests { @Test @MainActor func testPaywallConfiguration() async throws { var paywallCalled = false - + let config = StoreKitConfiguration() .withPurchases("com.test.pro") .withPaywall { context in paywallCalled = true return Text("Custom Paywall") } - + #expect(config.paywallBuilder != nil) - + // Test paywall builder let context = PaywallContext() _ = config.paywallBuilder?(context) #expect(paywallCalled) } + + @MainActor + @Test func testPaywallContextMarketingHelpers() { + // Create a context with empty products for testing + let context = PaywallContext( + triggeredBy: "test_feature", + availableProducts: [], + recommendedProduct: nil + ) + + // Test productsWithMarketing property with empty products + #expect(context.productsWithMarketing.isEmpty) + + // Test that the context initializes correctly + #expect(context.triggeredBy == "test_feature") + #expect(context.availableProducts.isEmpty) + #expect(context.recommendedProduct == nil) + } @Test @MainActor func testTermsAndPrivacyConfiguration() async throws { var termsCalled = false