Parcourir la source

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 il y a 4 semaines
Parent
commit
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)