diff --git a/README.md b/README.md index 4342089..84ba309 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 @@ -105,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" @@ -113,7 +131,7 @@ enum AppFeature: String, InAppKit.AppFeature { ContentView() .withPurchases(products: [ - Product("com.yourapp.pro", AppFeature.allCases) + Product("com.yourapp.pro", features: AppFeature.allCases) ]) ``` @@ -271,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 how the paywall was triggered: +The `PaywallContext` provides rich information about the paywall trigger and easy access to marketing data: + +> **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?)] } ``` @@ -381,19 +457,104 @@ 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 +### 🎨 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) @@ -426,6 +587,212 @@ 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") +``` + +### 🎯 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: @@ -600,98 +967,361 @@ 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 - using enhanced PaywallContext + 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: 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") { + 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: diff --git a/Sources/InAppKit/Configuration/ProductConfiguration.swift b/Sources/InAppKit/Configuration/ProductConfiguration.swift index 19bca2a..055a04d 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,17 +95,93 @@ 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 +/// 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/Sources/InAppKit/Configuration/StoreKitConfiguration.swift b/Sources/InAppKit/Configuration/StoreKitConfiguration.swift index f3f9907..22f0c0b 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 } @@ -69,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) } } 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..8d87def 100644 --- a/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift +++ b/Sources/InAppKit/UI/Components/PurchaseOptionCard.swift @@ -8,16 +8,94 @@ 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 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 { + // 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 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, style: .descriptive) + description += "\(trialLength) free trial • " + } + + // Add subscription period + let period = subscription.subscriptionPeriod + description += "\(periodText(period, style: .billing)) subscription" + + return description } return "Subscription • Auto-renewable" case .nonConsumable: @@ -32,8 +110,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, style: .billing) } return "Subscription" case .nonConsumable: @@ -45,98 +123,168 @@ 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 + private enum PeriodTextStyle { + case billing // "Monthly", "Annual" + case descriptive // "Every month", "Every year" + } - 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" - } + var body: some View { + PurchaseOptionCardView( + title: product.displayName, + description: productDescription, + price: product.displayPrice, + billingPeriod: billingPeriod, + badge: badge, + features: features, + savings: savings, + isSelected: isSelected, + onSelect: onSelect + ) } +} + +// MARK: - Shared UI Component + +private struct PurchaseOptionCardView: View { + let title: String + let description: String + let price: String + let billingPeriod: String + let badge: String? + let features: [String]? + let savings: String? + let isSelected: Bool + let onSelect: () -> Void var body: some View { Button(action: onSelect) { - HStack(spacing: 16) { - // Enhanced selection indicator + 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) ) - + if isSelected { Circle() .fill(Color.blue) - .frame(width: 10, height: 10) + .frame(width: CardStyle.selectionIndicatorFillSize, height: CardStyle.selectionIndicatorFillSize) } } - - VStack(alignment: .leading, spacing: 4) { - Text(product.displayName) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) - Text(productDescription) - .font(.system(size: 13, weight: .medium)) + VStack(alignment: .leading, spacing: CardStyle.contentVerticalSpacing) { + HStack { + Text(title) + .font(.system(size: CardStyle.titleFontSize, weight: .semibold)) + .foregroundColor(.primary) + + if let badge = badge { + Text(badge) + .font(.system(size: CardStyle.badgeFontSize, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, CardStyle.badgeHorizontalPadding) + .padding(.vertical, CardStyle.badgeVerticalPadding) + .background( + Capsule() + .fill(badge.lowercased().contains("popular") ? Color.orange : Color.blue) + ) + } + + Spacer() + } + + Text(description) + .font(.system(size: CardStyle.descriptionFontSize, weight: .medium)) .foregroundColor(.secondary) + + if let features = features, !features.isEmpty { + VStack(alignment: .leading, spacing: CardStyle.featuresSpacing) { + ForEach(features.prefix(2), id: \.self) { feature in + HStack(spacing: 4) { + Text("•") + .foregroundColor(.secondary) + .font(.system(size: CardStyle.featureFontSize)) + Text(feature) + .font(.system(size: CardStyle.featureFontSize)) + .foregroundColor(.secondary) + } + } + } + .padding(.top, CardStyle.featuresTopPadding) + } } Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(product.displayPrice) - .font(.system(size: 18, weight: .bold)) + VStack(alignment: .trailing, spacing: CardStyle.featuresSpacing) { + Text(price) + .font(.system(size: CardStyle.priceFontSize, weight: .bold)) .foregroundColor(.primary) + if let savings = savings { + Text(savings) + .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) } } @@ -144,112 +292,77 @@ struct PurchaseOptionCard: View { #if DEBUG #Preview("Purchase Option Cards") { - VStack(spacing: 16) { - Text("Purchase Option Cards") - .font(.title2.bold()) - .padding(.bottom) + VStack(spacing: 20) { + VStack(spacing: 8) { + Text("PurchaseOptionCard Preview") + .font(.title2.bold()) - VStack(spacing: 12) { - // Example showing different selection states - Group { - // Unselected Monthly Card - PurchaseOptionCardPreview( - title: "Pro Monthly", - description: "Monthly subscription • Auto-renewable", - price: "$9.99", - billingPeriod: "Monthly", - isSelected: false - ) - - // Selected Annual Card - PurchaseOptionCardPreview( - title: "Pro Annual", - description: "Annual subscription • Auto-renewable", - price: "$99.99", - billingPeriod: "Yearly", - isSelected: true - ) - - // Lifetime Purchase Card - PurchaseOptionCardPreview( - title: "Pro Lifetime", - description: "One-time purchase • Lifetime access", - price: "$199.99", - billingPeriod: "Lifetime", - isSelected: false - ) - } + Text("Different states and configurations") + .font(.caption) + .foregroundColor(.secondary) } - } - .padding() - .background(Color(NSColor.windowBackgroundColor)) -} -// MARK: - Preview Helper - -private struct PurchaseOptionCardPreview: View { - let title: String - let description: String - let price: String - let billingPeriod: String - let isSelected: Bool - - var body: some View { - Button(action: { }) { - HStack(spacing: 16) { - // 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) { - Text(title) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.primary) + 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") } + ) - Text(description) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.secondary) - } + // 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") } + ) - Spacer() + // 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(alignment: .trailing, spacing: 2) { - Text(price) - .font(.system(size: 18, weight: .bold)) - .foregroundColor(.primary) + VStack(spacing: 4) { + Text("Features Demonstrated:") + .font(.caption.bold()) + .foregroundColor(.secondary) - Text(billingPeriod) - .font(.system(size: 12, weight: .medium)) - .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) } - .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) - ) - ) + .font(.caption2) + .foregroundColor(.secondary) } - .buttonStyle(PlainButtonStyle()) - .scaleEffect(isSelected ? 1.02 : 1.0) - .animation(.easeInOut(duration: 0.15), value: isSelected) + .padding(.top, 8) } + .padding() + .background(Color(NSColor.windowBackgroundColor)) } #endif 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 { 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