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