Переглянути джерело

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 4 тижнів тому
батько
коміт
fdc44402cf

+ 2 - 0
.gitignore

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

+ 82 - 16
gramora/Managers/SubscriptionManager.swift

@@ -25,7 +25,7 @@ enum PurchaseError: Equatable {
         case .purchasePending:
             "This purchase is waiting for approval (for example, Ask to Buy)."
         case .noActiveSubscriptions:
-            "No active subscriptions were found for this Apple ID."
+            "No active purchases were found for this Apple ID."
         case .generic:
             "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 purchasingPlan: PaywallPlan?
     @Published private(set) var hasPremiumAccess = false
+    @Published private(set) var hasManageableSubscription = false
     @Published private(set) var hasResolvedPremiumStatus = false
     @Published var purchaseError: PurchaseError?
 
@@ -76,14 +77,16 @@ final class SubscriptionManager: ObservableObject {
         productsByID[plan.productID]
     }
 
+    func displayTitle(for plan: PaywallPlan) -> String {
+        product(for: plan)?.displayName ?? plan.rawValue.capitalized
+    }
+
     func mainPrice(for plan: PaywallPlan) -> String {
-        product(for: plan)?.displayPrice ?? plan.fallbackMainPrice
+        product(for: plan)?.displayPrice ?? "—"
     }
 
     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
 
@@ -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 {
         guard let product = product(for: plan) else {
-            return plan.fallbackBillingDescription
+            return "Loading pricing…"
         }
 
         if let subscription = product.subscription,
@@ -120,7 +160,7 @@ final class SubscriptionManager: ObservableObject {
         case .weekly:
             return "Billed at \(product.displayPrice) every week"
         case .monthly:
-            return "Billed at \(product.displayPrice) every month"
+            return "Billed at \(product.displayPrice) per month"
         case .yearly:
             return "Billed at \(product.displayPrice) every year"
         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 {
         purchaseError = nil
 
@@ -138,12 +195,10 @@ final class SubscriptionManager: ObservableObject {
         }
 
         guard let product else {
+            purchaseError = productsByID.isEmpty ? .productsLoadTimeout : .planUnavailable
+            #if DEBUG
             let missingProductIDs = SubscriptionProductID.all.filter { productsByID[$0] == nil }
-            if missingProductIDs.isEmpty {
-                purchaseError = .subscriptionUnavailable
-            } else {
-                purchaseError = .planUnavailable
-                #if DEBUG
+            if !missingProductIDs.isEmpty {
                 print(
                     """
                     [SubscriptionManager] Missing StoreKit products: \(missingProductIDs.joined(separator: ", "))
@@ -151,8 +206,8 @@ final class SubscriptionManager: ObservableObject {
                     Product > Scheme > Edit Scheme > Run > Options > StoreKit Configuration.
                     """
                 )
-                #endif
             }
+            #endif
             return false
         }
 
@@ -231,9 +286,13 @@ final class SubscriptionManager: ObservableObject {
             productsByID = map
             await refreshPremiumAccess()
         } catch is ProductLoadError {
-            purchaseError = .productsLoadTimeout
+            if presentLoadingUI == true {
+                purchaseError = .productsLoadTimeout
+            }
         } catch {
-            purchaseError = .generic
+            if presentLoadingUI == true {
+                purchaseError = .generic
+            }
         }
     }
 
@@ -258,6 +317,7 @@ final class SubscriptionManager: ObservableObject {
 
     private func refreshPremiumAccess() async {
         var hasPremium = false
+        var manageableSubscription = false
 
         for await result in Transaction.currentEntitlements {
             guard case .verified(let transaction) = result else { continue }
@@ -269,14 +329,20 @@ final class SubscriptionManager: ObservableObject {
             }
 
             hasPremium = true
-            break
+            if SubscriptionProductID.recurring.contains(transaction.productID) {
+                manageableSubscription = true
+            }
         }
 
         if !hasPremium {
             hasPremium = await hasActiveSubscriptionStatus()
+            if hasPremium {
+                manageableSubscription = true
+            }
         }
 
         hasPremiumAccess = hasPremium
+        hasManageableSubscription = manageableSubscription
         hasResolvedPremiumStatus = true
     }
 

+ 0 - 58
gramora/Models/PaywallModels.swift

@@ -16,15 +16,6 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
 
     var id: String { rawValue }
 
-    var title: String {
-        switch self {
-        case .weekly: "Weekly"
-        case .monthly: "Monthly"
-        case .yearly: "Yearly"
-        case .lifetime: "Lifetime"
-        }
-    }
-
     var productID: String {
         switch self {
         case .weekly: SubscriptionProductID.weekly
@@ -33,53 +24,4 @@ enum PaywallPlan: String, CaseIterable, Identifiable {
         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" : [
 
   ],
   "products" : [
     {
-      "displayPrice" : "249.0",
+      "displayPrice" : "59.99",
       "familyShareable" : false,
-      "internalID" : "6738291001",
+      "internalID" : "6785220646",
       "localizations" : [
         {
-          "description" : "Unlock Gramora Pro forever with a single payment.",
+          "description" : "Unlock Grammar Pro Premium forever.",
           "displayName" : "Lifetime",
           "locale" : "en_US"
         }
       ],
-      "productID" : "com.Gramora.pro.lifetime",
+      "productID" : "com.grammarpro.pro.lifetime",
       "referenceName" : "Lifetime",
       "type" : "NonConsumable"
     }
   ],
   "settings" : {
+    "_applicationInternalID" : "6785216263",
+    "_askToBuyEnabled" : false,
+    "_billingGracePeriodEnabled" : false,
+    "_billingIssuesEnabled" : false,
+    "_developerTeamID" : "LY29MGDPPD",
+    "_disableDialogs" : false,
     "_failTransactionsEnabled" : false,
+    "_lastSynchronizedDate" : 804341970.10229504,
     "_locale" : "en_US",
+    "_renewalBillingIssuesEnabled" : false,
     "_storefront" : "USA",
     "_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" : [
     {
-      "id" : "21543201",
+      "id" : "22194226",
       "localizations" : [
 
       ],
-      "name" : "Gramora Pro",
+      "name" : "Grammar Pro Premium",
       "subscriptions" : [
         {
           "adHocOffers" : [
@@ -87,22 +62,22 @@
           "codeOffers" : [
 
           ],
-          "displayPrice" : "19.0",
+          "displayPrice" : "9.99",
           "familyShareable" : false,
-          "groupNumber" : 1,
-          "internalID" : "6738291002",
+          "groupNumber" : 2,
+          "internalID" : "6785219341",
           "introductoryOffer" : null,
           "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"
             }
           ],
-          "productID" : "com.Gramora.pro.weekly",
-          "recurringSubscriptionPeriod" : "P1W",
-          "referenceName" : "Weekly",
-          "subscriptionGroupID" : "21543201",
+          "productID" : "com.grammarpro.pro.monthly",
+          "recurringSubscriptionPeriod" : "P1M",
+          "referenceName" : "Monthly",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
 
@@ -115,26 +90,22 @@
           "codeOffers" : [
 
           ],
-          "displayPrice" : "39.0",
+          "displayPrice" : "4.99",
           "familyShareable" : false,
-          "groupNumber" : 2,
-          "internalID" : "6738291003",
-          "introductoryOffer" : {
-            "internalID" : "6738291004",
-            "paymentMode" : "free",
-            "subscriptionPeriod" : "P3D"
-          },
+          "groupNumber" : 1,
+          "internalID" : "6785219044",
+          "introductoryOffer" : null,
           "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"
             }
           ],
-          "productID" : "com.Gramora.pro.monthly",
-          "recurringSubscriptionPeriod" : "P1M",
-          "referenceName" : "Monthly",
-          "subscriptionGroupID" : "21543201",
+          "productID" : "com.grammarpro.pro.weekly",
+          "recurringSubscriptionPeriod" : "P1W",
+          "referenceName" : "Weekly",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
 
@@ -147,22 +118,27 @@
           "codeOffers" : [
 
           ],
-          "displayPrice" : "79.0",
+          "displayPrice" : "29.99",
           "familyShareable" : false,
           "groupNumber" : 3,
-          "internalID" : "6738291005",
-          "introductoryOffer" : null,
+          "internalID" : "6785220317",
+          "introductoryOffer" : {
+            "internalID" : "1455D143",
+            "numberOfPeriods" : 1,
+            "paymentMode" : "free",
+            "subscriptionPeriod" : "P3D"
+          },
           "localizations" : [
             {
-              "description" : "Yearly access to all Gramora Pro features.",
+              "description" : "Yearly access to all Grammar Pro Premium features.",
               "displayName" : "Yearly",
               "locale" : "en_US"
             }
           ],
-          "productID" : "com.Gramora.pro.yearly",
+          "productID" : "com.grammarpro.pro.yearly",
           "recurringSubscriptionPeriod" : "P1Y",
           "referenceName" : "Yearly",
-          "subscriptionGroupID" : "21543201",
+          "subscriptionGroupID" : "22194226",
           "type" : "RecurringSubscription",
           "winbackOffers" : [
 

+ 6 - 4
gramora/Utilities/SubscriptionProductID.swift

@@ -1,13 +1,15 @@
 import Foundation
 
 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 recurring: Set<String> = [weekly, monthly, yearly]
+
     static func plan(for productID: String) -> PaywallPlan? {
         switch productID {
         case weekly: .weekly

+ 1 - 1
gramora/ViewModels/PaywallViewModel.swift

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

+ 42 - 19
gramora/Views/PaywallView.swift

@@ -19,6 +19,17 @@ struct PaywallView: View {
         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 {
         GeometryReader { proxy in
             let horizontalInset = min(max(proxy.size.width * 0.03, 12), 34)
@@ -67,7 +78,7 @@ struct PaywallView: View {
         }
         .clearHostingBackground()
         .task {
-            await subscriptions.loadProducts()
+            await subscriptions.loadProducts(presentLoadingUI: true)
         }
         .alert(
             "Subscriptions",
@@ -125,10 +136,12 @@ struct PaywallView: View {
         HStack(spacing: 14) {
             ForEach(PaywallPlan.allCases) { plan in
                 PaywallPlanCardView(
-                    plan: plan,
+                    title: subscriptions.displayTitle(for: plan),
+                    tagText: subscriptions.tagText(for: plan),
                     isSelected: viewModel.selectedPlan == plan,
                     mainPrice: subscriptions.mainPrice(for: plan),
                     secondaryPrice: subscriptions.secondaryPrice(for: plan),
+                    showsStrikethroughSecondaryPrice: subscriptions.showsStrikethroughSecondaryPrice(for: plan),
                     minHeight: planHeight,
                     onSelect: {
                         viewModel.selectPlan(plan)
@@ -162,7 +175,7 @@ struct PaywallView: View {
                             .controlSize(.small)
                             .tint(.white)
                     } else {
-                        Text(viewModel.selectedPlan.ctaTitle)
+                        Text(subscriptions.ctaTitle(for: viewModel.selectedPlan))
                             .font(.system(size: 20, weight: .heavy, design: .rounded))
                             .foregroundStyle(.white)
                             .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)
             .shadow(
                 color: isCTAHovered ? AppTheme.primaryShadow.opacity(0.32) : .clear,
@@ -194,7 +207,7 @@ struct PaywallView: View {
             .buttonStyle(.plain)
             .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))
                 .foregroundStyle(Color(red: 0.58, green: 0.61, blue: 0.66))
         }
@@ -202,19 +215,27 @@ struct PaywallView: View {
 
     private var legalSection: some View {
         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 {
                 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)
                 PaywallFooterLinkView(title: "Restore Purchase") {
@@ -319,10 +340,12 @@ private struct PaywallFeatureBox: View {
 }
 
 private struct PaywallPlanCardView: View {
-    let plan: PaywallPlan
+    let title: String
+    let tagText: String?
     let isSelected: Bool
     let mainPrice: String
     let secondaryPrice: String
+    let showsStrikethroughSecondaryPrice: Bool
     let minHeight: CGFloat
     let onSelect: () -> Void
     @State private var isHovered = false
@@ -344,7 +367,7 @@ private struct PaywallPlanCardView: View {
 
                     Spacer()
 
-                    if let tagText = plan.tagText {
+                    if let tagText {
                         Text(tagText)
                             .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))
@@ -357,7 +380,7 @@ private struct PaywallPlanCardView: View {
                 .padding(.horizontal, 14)
                 .padding(.top, 12)
 
-                Text(plan.title)
+                Text(title)
                     .font(.system(size: 15, weight: .semibold))
                     .foregroundStyle(AppTheme.textPrimary)
                     .padding(.horizontal, 14)
@@ -378,7 +401,7 @@ private struct PaywallPlanCardView: View {
                             .font(.system(size: 14, weight: .semibold))
                             .foregroundStyle(AppTheme.textSecondary)
                             .overlay(alignment: .center) {
-                                if plan == .weekly || plan == .lifetime {
+                                if showsStrikethroughSecondaryPrice {
                                     Rectangle()
                                         .fill(AppTheme.textSecondary)
                                         .frame(height: 1)