Просмотр исходного кода

Wire paywall to App Store Connect StoreKit products.

Align IAP identifiers with Paywall.storekit, drive plan pricing and copy from StoreKit, and ignore local App Store Connect secrets.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hussain Afzal 3 месяцев назад
Родитель
Сommit
fdc44402cf

+ 2 - 0
.gitignore

@@ -5,3 +5,5 @@ DerivedData/
 .derivedData/
 .derivedData/
 build/
 build/
 gramora/Resources/Secrets.plist
 gramora/Resources/Secrets.plist
+.env
+AuthKey_7WY795NZ4R.p8

+ 82 - 16
gramora/Managers/SubscriptionManager.swift

@@ -25,7 +25,7 @@ enum PurchaseError: Equatable {
         case .purchasePending:
         case .purchasePending:
             "This purchase is waiting for approval (for example, Ask to Buy)."
             "This purchase is waiting for approval (for example, Ask to Buy)."
         case .noActiveSubscriptions:
         case .noActiveSubscriptions:
-            "No active subscriptions were found for this Apple ID."
+            "No active purchases were found for this Apple ID."
         case .generic:
         case .generic:
             "Something went wrong with your subscription. Please try again."
             "Something went wrong with your subscription. Please try again."
         }
         }
@@ -42,6 +42,7 @@ final class SubscriptionManager: ObservableObject {
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var isLoadingProducts = false
     @Published private(set) var purchasingPlan: PaywallPlan?
     @Published private(set) var purchasingPlan: PaywallPlan?
     @Published private(set) var hasPremiumAccess = false
     @Published private(set) var hasPremiumAccess = false
+    @Published private(set) var hasManageableSubscription = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published var purchaseError: PurchaseError?
     @Published var purchaseError: PurchaseError?
 
 
@@ -76,14 +77,16 @@ final class SubscriptionManager: ObservableObject {
         productsByID[plan.productID]
         productsByID[plan.productID]
     }
     }
 
 
+    func displayTitle(for plan: PaywallPlan) -> String {
+        product(for: plan)?.displayName ?? plan.rawValue.capitalized
+    }
+
     func mainPrice(for plan: PaywallPlan) -> String {
     func mainPrice(for plan: PaywallPlan) -> String {
-        product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
+        product(for: plan)?.displayPrice ?? "—"
     }
     }
 
 
     func secondaryPrice(for plan: PaywallPlan) -> String {
     func secondaryPrice(for plan: PaywallPlan) -> String {
-        guard let product = product(for: plan) else {
-            return plan.fallbackSecondaryPrice
-        }
+        guard let product = product(for: plan) else { return "—" }
 
 
         let formatStyle = product.priceFormatStyle
         let formatStyle = product.priceFormatStyle
 
 
@@ -103,9 +106,46 @@ final class SubscriptionManager: ObservableObject {
         }
         }
     }
     }
 
 
+    func tagText(for plan: PaywallPlan) -> String? {
+        guard let product = product(for: plan) else { return nil }
+
+        if let subscription = product.subscription,
+           let intro = subscription.introductoryOffer,
+           intro.paymentMode == .freeTrial {
+            return "Free Trial"
+        }
+
+        switch plan {
+        case .weekly:
+            return "Basic"
+        case .yearly:
+            return savingsTagText(for: plan)
+        case .lifetime:
+            return "Pay Once"
+        case .monthly:
+            return nil
+        }
+    }
+
+    func ctaTitle(for plan: PaywallPlan) -> String {
+        if let product = product(for: plan),
+           let subscription = product.subscription,
+           let intro = subscription.introductoryOffer,
+           intro.paymentMode == .freeTrial {
+            return "START FOR FREE"
+        }
+
+        switch plan {
+        case .weekly: return "START WEEKLY PLAN"
+        case .monthly: return "START MONTHLY PLAN"
+        case .yearly: return "START YEARLY PLAN"
+        case .lifetime: return "UNLOCK LIFETIME"
+        }
+    }
+
     func billingDescription(for plan: PaywallPlan) -> String {
     func billingDescription(for plan: PaywallPlan) -> String {
         guard let product = product(for: plan) else {
         guard let product = product(for: plan) else {
-            return plan.fallbackBillingDescription
+            return "Loading pricing…"
         }
         }
 
 
         if let subscription = product.subscription,
         if let subscription = product.subscription,
@@ -120,7 +160,7 @@ final class SubscriptionManager: ObservableObject {
         case .weekly:
         case .weekly:
             return "Billed at \(product.displayPrice) every week"
             return "Billed at \(product.displayPrice) every week"
         case .monthly:
         case .monthly:
-            return "Billed at \(product.displayPrice) every month"
+            return "Billed at \(product.displayPrice) per month"
         case .yearly:
         case .yearly:
             return "Billed at \(product.displayPrice) every year"
             return "Billed at \(product.displayPrice) every year"
         case .lifetime:
         case .lifetime:
@@ -128,6 +168,23 @@ final class SubscriptionManager: ObservableObject {
         }
         }
     }
     }
 
 
+    func showsStrikethroughSecondaryPrice(for plan: PaywallPlan) -> Bool {
+        plan == .weekly || plan == .lifetime
+    }
+
+    private func savingsTagText(for plan: PaywallPlan) -> String? {
+        guard plan == .yearly,
+              let yearlyProduct = product(for: .yearly),
+              let weeklyProduct = product(for: .weekly),
+              weeklyProduct.price > 0 else { return nil }
+
+        let yearlyWeeklyEquivalent = yearlyProduct.price / Decimal(52)
+        let savings = (1 - (yearlyWeeklyEquivalent / weeklyProduct.price)) * 100
+        let rounded = Int(NSDecimalNumber(decimal: savings).doubleValue.rounded())
+        guard rounded > 0 else { return nil }
+        return "Save \(rounded)%"
+    }
+
     func purchase(_ plan: PaywallPlan) async -> Bool {
     func purchase(_ plan: PaywallPlan) async -> Bool {
         purchaseError = nil
         purchaseError = nil
 
 
@@ -138,12 +195,10 @@ final class SubscriptionManager: ObservableObject {
         }
         }
 
 
         guard let product else {
         guard let product else {
+            purchaseError = productsByID.isEmpty ? .productsLoadTimeout : .planUnavailable
+            #if DEBUG
             let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
             let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
-            if missingProductIDs.isEmpty {
-                purchaseError = .subscriptionUnavailable
-            } else {
-                purchaseError = .planUnavailable
-                #if DEBUG
+            if !missingProductIDs.isEmpty {
                 print(
                 print(
                     """
                     """
                     [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
                     [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
@@ -151,8 +206,8 @@ final class SubscriptionManager: ObservableObject {
                     Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
                     Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
                     """
                     """
                 )
                 )
-                #endif
             }
             }
+            #endif
             return false
             return false
         }
         }
 
 
@@ -231,9 +286,13 @@ final class SubscriptionManager: ObservableObject {
             productsByID = map
             productsByID = map
             await refreshPremiumAccess()
             await refreshPremiumAccess()
         } catch is ProductLoadError {
         } catch is ProductLoadError {
-            purchaseError = .productsLoadTimeout
+            if presentLoadingUI == true {
+                purchaseError = .productsLoadTimeout
+            }
         } catch {
         } catch {
-            purchaseError = .generic
+            if presentLoadingUI == true {
+                purchaseError = .generic
+            }
         }
         }
     }
     }
 
 
@@ -258,6 +317,7 @@ final class SubscriptionManager: ObservableObject {
 
 
     private func refreshPremiumAccess() async {
     private func refreshPremiumAccess() async {
         var hasPremium = false
         var hasPremium = false
+        var manageableSubscription = false
 
 
         for await result in Transaction.currentEntitlements {
         for await result in Transaction.currentEntitlements {
             guard case .verified(let transaction) = result else { continue }
             guard case .verified(let transaction) = result else { continue }
@@ -269,14 +329,20 @@ final class SubscriptionManager: ObservableObject {
             }
             }
 
 
             hasPremium = true
             hasPremium = true
-            break
+            if SubscriptionProductID.recurring.contains(transaction.productID) {
+                manageableSubscription = true
+            }
         }
         }
 
 
         if !hasPremium {
         if !hasPremium {
             hasPremium = await hasActiveSubscriptionStatus()
             hasPremium = await hasActiveSubscriptionStatus()
+            if hasPremium {
+                manageableSubscription = true
+            }
         }
         }
 
 
         hasPremiumAccess = hasPremium
         hasPremiumAccess = hasPremium
+        hasManageableSubscription = manageableSubscription
         hasResolvedPremiumStatus = true
         hasResolvedPremiumStatus = true
     }
     }
 
 

+ 0 - 58
gramora/Models/PaywallModels.swift

@@ -16,15 +16,6 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
 
 
     var id: String { rawValue }
     var id: String { rawValue }
 
 
-    var title: String {
-        switch self {
-        case .weekly: "Weekly"
-        case .monthly: "Monthly"
-        case .yearly: "Yearly"
-        case .lifetime: "Lifetime"
-        }
-    }
-
     var productID: String {
     var productID: String {
         switch self {
         switch self {
         case .weekly: SubscriptionProductID.weekly
         case .weekly: SubscriptionProductID.weekly
@@ -33,53 +24,4 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         case .lifetime: SubscriptionProductID.lifetime
         case .lifetime: SubscriptionProductID.lifetime
         }
         }
     }
     }
-
-    var fallbackMainPrice: String {
-        switch self {
-        case .weekly: "$19.00"
-        case .monthly: "$39.00"
-        case .yearly: "$79.00"
-        case .lifetime: "$249.00"
-        }
-    }
-
-    var fallbackSecondaryPrice: String {
-        switch self {
-        case .weekly: "$38.00 / week"
-        case .monthly: "$9.75 / week"
-        case .yearly: "$1.52 / week"
-        case .lifetime: "$498.00"
-        }
-    }
-
-    var tagText: String? {
-        switch self {
-        case .weekly: "Basic"
-        case .monthly: "Free Trial"
-        case .yearly: "Save 92%"
-        case .lifetime: "Pay Once"
-        }
-    }
-
-    var ctaTitle: String {
-        switch self {
-        case .weekly: "START WEEKLY PLAN"
-        case .monthly: "START FOR FREE"
-        case .yearly: "START YEARLY PLAN"
-        case .lifetime: "UNLOCK LIFETIME"
-        }
-    }
-
-    var fallbackBillingDescription: String {
-        switch self {
-        case .weekly:
-            return "Billed at \(fallbackMainPrice) every week"
-        case .monthly:
-            return "3 Days Free Trial, then \(fallbackMainPrice) per month"
-        case .yearly:
-            return "Billed at \(fallbackMainPrice) every year"
-        case .lifetime:
-            return "One-time payment of \(fallbackMainPrice)"
-        }
-    }
 }
 }

+ 58 - 82
gramora/Paywall.storekit

@@ -1,84 +1,59 @@
 {
 {
-  "identifier" : "Paywall",
+  "appPolicies" : {
+    "eula" : "",
+    "policies" : [
+      {
+        "locale" : "en_US",
+        "policyText" : "",
+        "policyURL" : ""
+      }
+    ]
+  },
+  "identifier" : "2E20B043",
   "nonRenewingSubscriptions" : [
   "nonRenewingSubscriptions" : [
 
 
   ],
   ],
   "products" : [
   "products" : [
     {
     {
-      "displayPrice" : "249.0",
+      "displayPrice" : "59.99",
       "familyShareable" : false,
       "familyShareable" : false,
-      "internalID" : "6738291001",
+      "internalID" : "6785220646",
       "localizations" : [
       "localizations" : [
         {
         {
-          "description" : "Unlock Gramora Pro forever with a single payment.",
+          "description" : "Unlock Grammar Pro Premium forever.",
           "displayName" : "Lifetime",
           "displayName" : "Lifetime",
           "locale" : "en_US"
           "locale" : "en_US"
         }
         }
       ],
       ],
-      "productID" : "com.Gramora.pro.lifetime",
+      "productID" : "com.grammarpro.pro.lifetime",
       "referenceName" : "Lifetime",
       "referenceName" : "Lifetime",
       "type" : "NonConsumable"
       "type" : "NonConsumable"
     }
     }
   ],
   ],
   "settings" : {
   "settings" : {
+    "_applicationInternalID" : "6785216263",
+    "_askToBuyEnabled" : false,
+    "_billingGracePeriodEnabled" : false,
+    "_billingIssuesEnabled" : false,
+    "_developerTeamID" : "LY29MGDPPD",
+    "_disableDialogs" : false,
     "_failTransactionsEnabled" : false,
     "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 804341970.10229504,
     "_locale" : "en_US",
     "_locale" : "en_US",
+    "_renewalBillingIssuesEnabled" : false,
     "_storefront" : "USA",
     "_storefront" : "USA",
     "_storeKitErrors" : [
     "_storeKitErrors" : [
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Load Products"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Purchase"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Verification"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "App Store Sync"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Subscription Status"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "App Transaction"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Manage Subscriptions Sheet"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Refund Request Sheet"
-      },
-      {
-        "current" : null,
-        "enabled" : false,
-        "name" : "Offer Code Redeem Sheet"
-      }
-    ]
+
+    ],
+    "_timeRate" : 0
   },
   },
   "subscriptionGroups" : [
   "subscriptionGroups" : [
     {
     {
-      "id" : "21543201",
+      "id" : "22194226",
       "localizations" : [
       "localizations" : [
 
 
       ],
       ],
-      "name" : "Gramora Pro",
+      "name" : "Grammar Pro Premium",
       "subscriptions" : [
       "subscriptions" : [
         {
         {
           "adHocOffers" : [
           "adHocOffers" : [
@@ -87,22 +62,22 @@
           "codeOffers" : [
           "codeOffers" : [
 
 
           ],
           ],
-          "displayPrice" : "19.0",
+          "displayPrice" : "9.99",
           "familyShareable" : false,
           "familyShareable" : false,
-          "groupNumber" : 1,
-          "internalID" : "6738291002",
+          "groupNumber" : 2,
+          "internalID" : "6785219341",
           "introductoryOffer" : null,
           "introductoryOffer" : null,
           "localizations" : [
           "localizations" : [
             {
             {
-              "description" : "Weekly access to all Gramora Pro features.",
-              "displayName" : "Weekly",
+              "description" : "Monthly access to all Grammar Pro Premium features.",
+              "displayName" : "Monthly",
               "locale" : "en_US"
               "locale" : "en_US"
             }
             }
           ],
           ],
-          "productID" : "com.Gramora.pro.weekly",
-          "recurringSubscriptionPeriod" : "P1W",
-          "referenceName" : "Weekly",
-          "subscriptionGroupID" : "21543201",
+          "productID" : "com.grammarpro.pro.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Monthly",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
           "winbackOffers" : [
 
 
@@ -115,26 +90,22 @@
           "codeOffers" : [
           "codeOffers" : [
 
 
           ],
           ],
-          "displayPrice" : "39.0",
+          "displayPrice" : "4.99",
           "familyShareable" : false,
           "familyShareable" : false,
-          "groupNumber" : 2,
-          "internalID" : "6738291003",
-          "introductoryOffer" : {
-            "internalID" : "6738291004",
-            "paymentMode" : "free",
-            "subscriptionPeriod" : "P3D"
-          },
+          "groupNumber" : 1,
+          "internalID" : "6785219044",
+          "introductoryOffer" : null,
           "localizations" : [
           "localizations" : [
             {
             {
-              "description" : "Monthly access to all Gramora Pro features.",
-              "displayName" : "Monthly",
+              "description" : "Weekly access to all Grammar Pro Premium features.",
+              "displayName" : "Weekly",
               "locale" : "en_US"
               "locale" : "en_US"
             }
             }
           ],
           ],
-          "productID" : "com.Gramora.pro.monthly",
-          "recurringSubscriptionPeriod" : "P1M",
-          "referenceName" : "Monthly",
-          "subscriptionGroupID" : "21543201",
+          "productID" : "com.grammarpro.pro.weekly",
+          "recurringSubscriptionPeriod" : "P1W",
+          "referenceName" : "Weekly",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
           "winbackOffers" : [
 
 
@@ -147,22 +118,27 @@
           "codeOffers" : [
           "codeOffers" : [
 
 
           ],
           ],
-          "displayPrice" : "79.0",
+          "displayPrice" : "29.99",
           "familyShareable" : false,
           "familyShareable" : false,
           "groupNumber" : 3,
           "groupNumber" : 3,
-          "internalID" : "6738291005",
-          "introductoryOffer" : null,
+          "internalID" : "6785220317",
+          "introductoryOffer" : {
+            "internalID" : "1455D143",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
           "localizations" : [
           "localizations" : [
             {
             {
-              "description" : "Yearly access to all Gramora Pro features.",
+              "description" : "Yearly access to all Grammar Pro Premium features.",
               "displayName" : "Yearly",
               "displayName" : "Yearly",
               "locale" : "en_US"
               "locale" : "en_US"
             }
             }
           ],
           ],
-          "productID" : "com.Gramora.pro.yearly",
+          "productID" : "com.grammarpro.pro.yearly",
           "recurringSubscriptionPeriod" : "P1Y",
           "recurringSubscriptionPeriod" : "P1Y",
           "referenceName" : "Yearly",
           "referenceName" : "Yearly",
-          "subscriptionGroupID" : "21543201",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
           "winbackOffers" : [
 
 

+ 6 - 4
gramora/Utilities/SubscriptionProductID.swift

@@ -1,13 +1,15 @@
 import Foundation
 import Foundation
 
 
 enum SubscriptionProductID {
 enum SubscriptionProductID {
-    static let weekly = "com.Gramora.pro.weekly"
-    static let monthly = "com.Gramora.pro.monthly"
-    static let yearly = "com.Gramora.pro.yearly"
-    static let lifetime = "com.Gramora.pro.lifetime"
+    static let weekly = "com.grammarpro.pro.weekly"
+    static let monthly = "com.grammarpro.pro.monthly"
+    static let yearly = "com.grammarpro.pro.yearly"
+    static let lifetime = "com.grammarpro.pro.lifetime"
 
 
     static let all: Set<String> = [weekly, monthly, yearly, lifetime]
     static let all: Set<String> = [weekly, monthly, yearly, lifetime]
 
 
+    static let recurring: Set<String> = [weekly, monthly, yearly]
+
     static func plan(for productID: String) -> PaywallPlan? {
     static func plan(for productID: String) -> PaywallPlan? {
         switch productID {
         switch productID {
         case weekly: .weekly
         case weekly: .weekly

+ 1 - 1
gramora/ViewModels/PaywallViewModel.swift

@@ -3,7 +3,7 @@ import SwiftUI
 
 
 @MainActor
 @MainActor
 final class PaywallViewModel: ObservableObject {
 final class PaywallViewModel: ObservableObject {
-    @Published var selectedPlan: PaywallPlan = .monthly
+    @Published var selectedPlan: PaywallPlan = .yearly
 
 
     let features: [PaywallFeature] = [
     let features: [PaywallFeature] = [
         PaywallFeature(
         PaywallFeature(

+ 42 - 19
gramora/Views/PaywallView.swift

@@ -19,6 +19,17 @@ struct PaywallView: View {
         subscriptions.hasPremiumAccess
         subscriptions.hasPremiumAccess
     }
     }
 
 
+    private var isSelectedPlanLifetime: Bool {
+        viewModel.selectedPlan == .lifetime
+    }
+
+    private var footerPrimaryTitle: String {
+        if isPremium {
+            return subscriptions.hasManageableSubscription ? "Manage Subscription" : "Close"
+        }
+        return "Continue with free plan"
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
             let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
@@ -67,7 +78,7 @@ struct PaywallView: View {
         }
         }
         .clearHostingBackground()
         .clearHostingBackground()
         .task {
         .task {
-            await subscriptions.loadProducts()
+            await subscriptions.loadProducts(presentLoadingUI: true)
         }
         }
         .alert(
         .alert(
             "Subscriptions",
             "Subscriptions",
@@ -125,10 +136,12 @@ struct PaywallView: View {
         HStack(spacing: 14) {
         HStack(spacing: 14) {
             ForEach(PaywallPlan.allCases) { plan in
             ForEach(PaywallPlan.allCases) { plan in
                 PaywallPlanCardView(
                 PaywallPlanCardView(
-                    plan: plan,
+                    title: subscriptions.displayTitle(for: plan),
+                    tagText: subscriptions.tagText(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     isSelected: viewModel.selectedPlan == plan,
                     mainPrice: subscriptions.mainPrice(for: plan),
                     mainPrice: subscriptions.mainPrice(for: plan),
                     secondaryPrice: subscriptions.secondaryPrice(for: plan),
                     secondaryPrice: subscriptions.secondaryPrice(for: plan),
+                    showsStrikethroughSecondaryPrice: subscriptions.showsStrikethroughSecondaryPrice(for: plan),
                     minHeight: planHeight,
                     minHeight: planHeight,
                     onSelect: {
                     onSelect: {
                         viewModel.selectPlan(plan)
                         viewModel.selectPlan(plan)
@@ -162,7 +175,7 @@ struct PaywallView: View {
                             .controlSize(.small)
                             .controlSize(.small)
                             .tint(.white)
                             .tint(.white)
                     } else {
                     } else {
-                        Text(viewModel.selectedPlan.ctaTitle)
+                        Text(subscriptions.ctaTitle(for: viewModel.selectedPlan))
                             .font(.system(size: 20, weight: .heavy, design: .rounded))
                             .font(.system(size: 20, weight: .heavy, design: .rounded))
                             .foregroundStyle(.white)
                             .foregroundStyle(.white)
                             .lineLimit(1)
                             .lineLimit(1)
@@ -182,8 +195,8 @@ struct PaywallView: View {
                     }
                     }
                 }
                 }
             }
             }
-            .disabled(buttonsBusy || subscriptions.isLoadingProducts)
-            .opacity((buttonsBusy || subscriptions.isLoadingProducts) ? 0.7 : 1)
+            .disabled(buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded)
+            .opacity((buttonsBusy || subscriptions.isLoadingProducts || !subscriptions.hasAllProductsLoaded) ? 0.7 : 1)
             .scaleEffect(isCTAHovered ? 1.01 : 1.0)
             .scaleEffect(isCTAHovered ? 1.01 : 1.0)
             .shadow(
             .shadow(
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
@@ -194,7 +207,7 @@ struct PaywallView: View {
             .buttonStyle(.plain)
             .buttonStyle(.plain)
             .onHover { isCTAHovered = $0 }
             .onHover { isCTAHovered = $0 }
 
 
-            Text("No commitment, cancel anytime.")
+            Text(isSelectedPlanLifetime ? "One-time purchase, yours forever." : "No commitment, cancel anytime.")
                 .font(.system(size: 13, weight: .semibold))
                 .font(.system(size: 13, weight: .semibold))
                 .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
                 .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
         }
         }
@@ -202,19 +215,27 @@ struct PaywallView: View {
 
 
     private var legalSection: some View {
     private var legalSection: some View {
         VStack(spacing: 12) {
         VStack(spacing: 12) {
-            VStack(spacing: 2) {
-                Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
-                Text("Payment will be charged to your iTunes account at confirmation of purchase.")
+            if !isSelectedPlanLifetime {
+                VStack(spacing: 2) {
+                    Text("Your subscription will automatically renew unless auto-renew is turned off at least 24-hours before the end of the current subscription period.")
+                    Text("Payment will be charged to your iTunes account at confirmation of purchase.")
+                }
+                .padding(.top, -8)
+                .font(.system(size: 12, weight: .medium))
+                .foregroundStyle(Color(red: 0.59, green: 0.62, blue: 0.68))
+                .multilineTextAlignment(.center)
             }
             }
-            .padding(.top, -8)
-            .font(.system(size: 12, weight: .medium))
-            .foregroundStyle(Color(red: 0.59, green: 0.62, blue: 0.68))
-            .multilineTextAlignment(.center)
 
 
             HStack {
             HStack {
                 PaywallFooterLinkView(
                 PaywallFooterLinkView(
-                    title: isPremium ? "Manage Subscription" : "Continue with free plan",
-                    action: isPremium ? subscriptions.openSubscriptionManagement : onClose
+                    title: footerPrimaryTitle,
+                    action: {
+                        if isPremium, subscriptions.hasManageableSubscription {
+                            subscriptions.openSubscriptionManagement()
+                        } else {
+                            onClose()
+                        }
+                    }
                 )
                 )
                 Spacer(minLength: 0)
                 Spacer(minLength: 0)
                 PaywallFooterLinkView(title: "Restore Purchase") {
                 PaywallFooterLinkView(title: "Restore Purchase") {
@@ -319,10 +340,12 @@ private struct PaywallFeatureBox: View {
 }
 }
 
 
 private struct PaywallPlanCardView: View {
 private struct PaywallPlanCardView: View {
-    let plan: PaywallPlan
+    let title: String
+    let tagText: String?
     let isSelected: Bool
     let isSelected: Bool
     let mainPrice: String
     let mainPrice: String
     let secondaryPrice: String
     let secondaryPrice: String
+    let showsStrikethroughSecondaryPrice: Bool
     let minHeight: CGFloat
     let minHeight: CGFloat
     let onSelect: () -> Void
     let onSelect: () -> Void
     @State private var isHovered = false
     @State private var isHovered = false
@@ -344,7 +367,7 @@ private struct PaywallPlanCardView: View {
 
 
                     Spacer()
                     Spacer()
 
 
-                    if let tagText = plan.tagText {
+                    if let tagText {
                         Text(tagText)
                         Text(tagText)
                             .font(.system(size: 12, weight: .semibold))
                             .font(.system(size: 12, weight: .semibold))
                             .foregroundStyle(tagText == "Pay Once" ? Color(red: 0.53, green: 0.39, blue: 0.90) : Color(red: 0.07, green: 0.60, blue: 0.46))
                             .foregroundStyle(tagText == "Pay Once" ? Color(red: 0.53, green: 0.39, blue: 0.90) : Color(red: 0.07, green: 0.60, blue: 0.46))
@@ -357,7 +380,7 @@ private struct PaywallPlanCardView: View {
                 .padding(.horizontal, 14)
                 .padding(.horizontal, 14)
                 .padding(.top, 12)
                 .padding(.top, 12)
 
 
-                Text(plan.title)
+                Text(title)
                     .font(.system(size: 15, weight: .semibold))
                     .font(.system(size: 15, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
                     .foregroundStyle(AppTheme.textPrimary)
                     .padding(.horizontal, 14)
                     .padding(.horizontal, 14)
@@ -378,7 +401,7 @@ private struct PaywallPlanCardView: View {
                             .font(.system(size: 14, weight: .semibold))
                             .font(.system(size: 14, weight: .semibold))
                             .foregroundStyle(AppTheme.textSecondary)
                             .foregroundStyle(AppTheme.textSecondary)
                             .overlay(alignment: .center) {
                             .overlay(alignment: .center) {
-                                if plan == .weekly || plan == .lifetime {
+                                if showsStrikethroughSecondaryPrice {
                                     Rectangle()
                                     Rectangle()
                                         .fill(AppTheme.textSecondary)
                                         .fill(AppTheme.textSecondary)
                                         .frame(height: 1)
                                         .frame(height: 1)