Преглед на файлове

Sync paywall with bundled Products.storekit for dynamic plan discovery.

StoreKit configuration now drives available products, ordering, and pricing on the paywall so new plans like weekly appear without code changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 преди 2 седмици
родител
ревизия
14b45a3699

+ 8 - 7
smart_printer/AppStoreConfig.swift

@@ -1,7 +1,7 @@
 import Foundation
 
 /// Production App Store identifiers for Printer App - All Printers.
-/// Keep these aligned with Xcode, App Store Connect, and `Products.storekit`.
+/// Product IDs are sourced from bundled `Products.storekit`; marketing copy stays in `paywall.json`.
 enum AppStoreConfig {
     static let displayName = "Printer App - All Printers"
     static let proDisplayName = "\(displayName) Pro"
@@ -24,12 +24,13 @@ enum AppStoreConfig {
     }
 
     enum ProductID {
-        // ASC allows only alphanumeric characters, underscores, and periods.
-        // Keep aligned with `Products.storekit` and bundled `paywall.json` plan productIDs.
-        static let monthly = PaywallConfig.loadBundled().plans.monthly.productID
-        static let yearly = PaywallConfig.loadBundled().plans.yearly.productID
-        static let lifetime = PaywallConfig.loadBundled().plans.lifetime.productID
+        static let monthly = StoreKitCatalog.shared.productID(for: .monthly)
+            ?? PaywallConfig.loadBundled().plans.monthly.productID
+        static let yearly = StoreKitCatalog.shared.productID(for: .yearly)
+            ?? PaywallConfig.loadBundled().plans.yearly.productID
+        static let lifetime = StoreKitCatalog.shared.productID(for: .lifetime)
+            ?? PaywallConfig.loadBundled().plans.lifetime.productID
 
-        static let all: Set<String> = PaywallConfig.loadBundled().allProductIDs
+        static let all: Set<String> = StoreKitCatalog.allProductIDs
     }
 }

+ 21 - 29
smart_printer/Managers/StoreManager.swift

@@ -45,12 +45,12 @@ final class StoreManager {
     private(set) var purchaseError: PurchaseError?
 
     var isPro: Bool { isPremium }
-    var hasActiveSubscription: Bool { activePremiumPlan == .monthly || activePremiumPlan == .yearly }
-    var hasLifetimeAccess: Bool { activePremiumPlan == .lifetime }
+    var hasActiveSubscription: Bool { activePremiumPlan?.isSubscription == true }
+    var hasLifetimeAccess: Bool { activePremiumPlan?.isLifetime == true }
     var premiumAccessKind: PremiumAccessKind {
-        switch activePremiumPlan {
+        switch activePremiumPlan?.kind {
         case .lifetime: .lifetime
-        case .monthly, .yearly: .subscription
+        case .weekly, .monthly, .yearly: .subscription
         case nil: .none
         }
     }
@@ -103,9 +103,9 @@ final class StoreManager {
         let migratedPlan: String?
         switch legacyKind {
         case PremiumAccessKind.lifetime.rawValue:
-            migratedPlan = PaywallPlan.lifetime.rawValue
+            migratedPlan = StoreKitCatalog.shared.productID(for: .lifetime)
         case PremiumAccessKind.subscription.rawValue:
-            migratedPlan = PaywallPlan.yearly.rawValue
+            migratedPlan = StoreKitCatalog.shared.productID(for: .yearly)
         default:
             migratedPlan = nil
         }
@@ -118,23 +118,23 @@ final class StoreManager {
     private func restoredCachedPremiumPlan() -> PaywallPlan? {
         guard UserDefaults.standard.bool(forKey: Self.cachedPremiumKey) else { return nil }
 
-        if let raw = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
-           let plan = PaywallPlan(rawValue: raw) {
+        if let stored = UserDefaults.standard.string(forKey: Self.cachedPremiumPlanKey),
+           let plan = PaywallPlan.fromStoredValue(stored) {
             return plan
         }
 
         if let legacyKind = UserDefaults.standard.string(forKey: "StoreManager.cachedPremiumAccessKind") {
             switch legacyKind {
             case PremiumAccessKind.lifetime.rawValue:
-                return .lifetime
+                return StoreKitCatalog.shared.productID(for: .lifetime).map(PaywallPlan.init(productID:))
             case PremiumAccessKind.subscription.rawValue:
-                return .yearly
+                return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
             default:
                 break
             }
         }
 
-        return .yearly
+        return StoreKitCatalog.shared.productID(for: .yearly).map(PaywallPlan.init(productID:))
     }
 
     func start() {
@@ -418,7 +418,7 @@ final class StoreManager {
             }
         }
 
-        if resolvedPlan == nil || resolvedPlan != .lifetime {
+        if resolvedPlan == nil || resolvedPlan?.isLifetime != true {
             if let subscriptionPlan = await activeSubscriptionPlan() {
                 resolvedPlan = preferredPlan(resolvedPlan, subscriptionPlan)
             }
@@ -591,9 +591,9 @@ final class StoreManager {
     private func applyPremiumStatus(plan: PaywallPlan?) {
         let hasPremium = plan != nil
         let kind: PremiumAccessKind
-        switch plan {
+        switch plan?.kind {
         case .lifetime: kind = .lifetime
-        case .monthly, .yearly: kind = .subscription
+        case .weekly, .monthly, .yearly: kind = .subscription
         case nil: kind = .none
         }
 
@@ -605,7 +605,7 @@ final class StoreManager {
         activePremiumPlan = plan
         UserDefaults.standard.set(hasPremium, forKey: Self.cachedPremiumKey)
         if let plan {
-            UserDefaults.standard.set(plan.rawValue, forKey: Self.cachedPremiumPlanKey)
+            UserDefaults.standard.set(plan.productID, forKey: Self.cachedPremiumPlanKey)
         } else {
             UserDefaults.standard.removeObject(forKey: Self.cachedPremiumPlanKey)
         }
@@ -631,20 +631,13 @@ final class StoreManager {
     }
 
     private func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
-        if current == .lifetime || candidate == .lifetime {
-            return .lifetime
-        }
-        if current == .yearly || candidate == .yearly {
-            return .yearly
-        }
-        if current == .monthly || candidate == .monthly {
-            return .monthly
-        }
-        return candidate
+        StoreKitCatalog.shared.preferredPlan(current, candidate)
     }
 
     private func activeLifetimePlan() async -> PaywallPlan? {
-        let lifetimeProductID = PaywallPlan.lifetime.productID
+        guard let lifetimeProductID = StoreKitCatalog.shared.productID(for: .lifetime) else {
+            return nil
+        }
         guard let result = await Transaction.latest(for: lifetimeProductID) else {
             return nil
         }
@@ -657,7 +650,7 @@ final class StoreManager {
 
         for await result in Transaction.currentEntitlements {
             guard case .verified(let transaction) = result else { continue }
-            guard let plan = planGrantingPremium(from: transaction), plan != .lifetime else { continue }
+            guard let plan = planGrantingPremium(from: transaction), !plan.isLifetime else { continue }
             resolvedPlan = preferredPlan(resolvedPlan, plan)
         }
 
@@ -665,8 +658,7 @@ final class StoreManager {
     }
 
     private func activePlanFromSubscriptionStatus() async -> PaywallPlan? {
-        let subscriptionPlans = [PaywallPlan.monthly, .yearly]
-        for plan in subscriptionPlans {
+        for plan in StoreKitCatalog.shared.subscriptionPlans {
             let productID = plan.productID
             let product: Product?
             if let cached = productsByID[productID] {

+ 68 - 6
smart_printer/PaywallConfig.swift

@@ -279,10 +279,6 @@ extension PaywallConfig {
         return formatDuration(count: fallback.count, unit: unit, lowercase: lowercase)
     }
 
-    var trialEligiblePlan: PaywallPlan? {
-        PaywallPlan(rawValue: trial.eligiblePlan)
-    }
-
     private func subscriptionPeriodUnit(from value: String) -> Product.SubscriptionPeriod.Unit? {
         switch value {
         case "day": .day
@@ -293,12 +289,78 @@ extension PaywallConfig {
         }
     }
 
+    var trialEligiblePlan: PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: trial.eligiblePlan)
+    }
+
     var allProductIDs: Set<String> {
-        Set(PaywallPlan.displayOrder.map(\.productID))
+        StoreKitCatalog.allProductIDs
     }
 
     func plan(for productID: String) -> PaywallPlan? {
-        PaywallPlan.displayOrder.first { $0.productID == productID }
+        guard StoreKitCatalog.shared.contains(productID: productID) else { return nil }
+        return PaywallPlan(productID: productID)
+    }
+
+    func planCopy(for productID: String) -> PlanCopy? {
+        if productID == plans.monthly.productID { return plans.monthly }
+        if productID == plans.yearly.productID { return plans.yearly }
+        if productID == plans.lifetime.productID { return plans.lifetime }
+        return nil
+    }
+
+    func synthesizedPlanCopy(for product: Product) -> PlanCopy {
+        let title = product.displayName
+
+        if product.type == .nonConsumable {
+            return PlanCopy(
+                productID: product.id,
+                title: title,
+                subtitleTemplate: plans.lifetime.subtitleTemplate,
+                ctaTemplate: plans.lifetime.ctaTemplate,
+                fallbackPrice: loadingPrice,
+                priceSuffix: plans.lifetime.priceSuffix,
+                fallbackBillingDescription: plans.lifetime.fallbackBillingDescription,
+                features: plans.lifetime.features
+            )
+        }
+
+        guard let subscription = product.subscription else {
+            return placeholderPlanCopy(for: product.id)
+        }
+
+        let periodLower = subscriptionPeriodDescription(from: subscription.subscriptionPeriod)
+        let periodTitle = formatDuration(
+            count: subscription.subscriptionPeriod.value,
+            unit: subscription.subscriptionPeriod.unit,
+            lowercase: false
+        )
+
+        return PlanCopy(
+            productID: product.id,
+            title: title,
+            subtitleTemplate: "{price} / \(periodLower), cancel anytime",
+            trialSubtitleTemplate: "{trial} free, then {price} / \(periodLower)",
+            ctaTemplate: "Subscribe for {price} / \(periodTitle)",
+            fallbackPrice: loadingPrice,
+            priceSuffix: "/\(periodLower)",
+            fallbackBillingDescription: "Billed at {price} every \(periodLower)",
+            features: plans.monthly.features
+        )
+    }
+
+    func placeholderPlanCopy(for productID: String) -> PlanCopy {
+        let title = StoreKitCatalog.shared.entry(for: productID)?.displayName ?? "Premium"
+        return PlanCopy(
+            productID: productID,
+            title: title,
+            subtitleTemplate: "{price}, cancel anytime",
+            ctaTemplate: "Subscribe for {price}",
+            fallbackPrice: loadingPrice,
+            priceSuffix: nil,
+            fallbackBillingDescription: "Billed at {price}",
+            features: plans.monthly.features
+        )
     }
 }
 

+ 48 - 31
smart_printer/PaywallModels.swift

@@ -1,23 +1,41 @@
 import Foundation
 import StoreKit
 
-enum PaywallPlan: String, CaseIterable {
-    case monthly
-    case yearly
-    case lifetime
+struct PaywallPlan: Hashable, Sendable {
+    let productID: String
 
-    static let displayOrder: [PaywallPlan] = [.monthly, .yearly, .lifetime]
+    init(productID: String) {
+        self.productID = productID
+    }
+
+    static var displayOrder: [PaywallPlan] {
+        StoreKitCatalog.orderedPlans
+    }
+
+    var kind: PaywallPlanKind? {
+        StoreKitCatalog.shared.kind(for: productID)
+    }
+
+    var isLifetime: Bool {
+        kind == .lifetime
+    }
+
+    var isSubscription: Bool {
+        kind != .lifetime && kind != nil
+    }
 
-    var productID: String {
-        planCopy(from: PaywallConfig.loadBundled()).productID
+    static func fromStoredValue(_ value: String) -> PaywallPlan? {
+        StoreKitCatalog.shared.plan(matching: value)
     }
 
-    func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
-        switch self {
-        case .monthly: config.plans.monthly
-        case .yearly: config.plans.yearly
-        case .lifetime: config.plans.lifetime
+    func planCopy(from config: PaywallConfig, product: Product? = nil) -> PaywallConfig.PlanCopy {
+        if let copy = config.planCopy(for: productID) {
+            return copy
         }
+        if let product {
+            return config.synthesizedPlanCopy(for: product)
+        }
+        return config.placeholderPlanCopy(for: productID)
     }
 
     func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
@@ -30,7 +48,7 @@ enum PaywallPlan: String, CaseIterable {
         trial: PaywallTrialDisplay? = nil
     ) -> String {
         guard let product else { return config.loadingPrice }
-        let copy = planCopy(from: config)
+        let copy = planCopy(from: config, product: product)
         let price = product.displayPrice
 
         if let trial, let trialTemplate = copy.trialSubtitleTemplate {
@@ -51,7 +69,7 @@ enum PaywallPlan: String, CaseIterable {
         if let trial {
             return trial.ctaText
         }
-        let copy = planCopy(from: config)
+        let copy = planCopy(from: config, product: product)
         return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: product.displayPrice)
     }
 
@@ -60,27 +78,26 @@ enum PaywallPlan: String, CaseIterable {
         config: PaywallConfig,
         trial: PaywallTrialDisplay? = nil
     ) -> String {
-        switch self {
-        case .lifetime:
+        if isLifetime {
             return config.lifetimeDisclosure
-        case .monthly, .yearly:
-            guard let product else { return config.loadingDisclosure }
-            let price = product.displayPrice
-            let period = product.subscription.map {
-                config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
-            } ?? config.messages.periodFallback
-
-            if let trial {
-                return config.trialDisclosureTemplate
-                    .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
-                    .replacingOccurrences(of: "{price}", with: price)
-                    .replacingOccurrences(of: "{period}", with: period)
-            }
-
-            return config.subscriptionDisclosureTemplate
+        }
+
+        guard let product else { return config.loadingDisclosure }
+        let price = product.displayPrice
+        let period = product.subscription.map {
+            config.subscriptionPeriodDescription(from: $0.subscriptionPeriod)
+        } ?? config.messages.periodFallback
+
+        if let trial {
+            return config.trialDisclosureTemplate
+                .replacingOccurrences(of: "{trial}", with: trial.durationLowercased)
                 .replacingOccurrences(of: "{price}", with: price)
                 .replacingOccurrences(of: "{period}", with: period)
         }
+
+        return config.subscriptionDisclosureTemplate
+            .replacingOccurrences(of: "{price}", with: price)
+            .replacingOccurrences(of: "{period}", with: period)
     }
 }
 

+ 7 - 6
smart_printer/PaywallView.swift

@@ -175,7 +175,8 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         layer?.masksToBounds = false
 
         let config = PaywallConfigService.shared.config
-        titleLabel.stringValue = plan.planCopy(from: config).title
+        titleLabel.stringValue = StoreKitCatalog.shared.entry(for: plan.productID)?.displayName
+            ?? plan.planCopy(from: config).title
         titleLabel.font = AppTheme.semiboldFont(size: 15)
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
@@ -224,7 +225,7 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         config: PaywallConfig,
         trial: PaywallTrialDisplay? = nil
     ) {
-        titleLabel.stringValue = plan.planCopy(from: config).title
+        titleLabel.stringValue = plan.planCopy(from: config, product: product).title
         subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, trial: trial)
         priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
         updateTrialBadge(trial: trial)
@@ -623,7 +624,7 @@ private final class PaywallCTAButton: NSButton, AppearanceRefreshable {
 final class PaywallView: NSView, AppearanceRefreshable {
     var onClose: (() -> Void)?
 
-    private var selectedPlan: PaywallPlan = .yearly
+    private var selectedPlan: PaywallPlan = StoreKitCatalog.shared.defaultSelectedPlan()
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = PaywallCTAButton()
     private let renewalDisclosureLabel = NSTextField(wrappingLabelWithString: "")
@@ -823,7 +824,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
                     ? trialPlan
                     : nil
             }
-            ?? availablePlans.first(where: { $0 == .yearly })
+            ?? availablePlans.first(where: { $0.kind == .yearly })
             ?? availablePlans[0]
 
         selectPlan(preferred)
@@ -845,7 +846,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
     private func refreshSecurePaymentNote() {
         let config = paywallConfig
-        securePaymentLabel.stringValue = selectedPlan == .lifetime
+        securePaymentLabel.stringValue = selectedPlan.isLifetime
             ? config.lifetimeSecurePaymentNote
             : config.securePaymentNote
     }
@@ -903,7 +904,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
         guard trustItemViews.count > 1, paywallConfig.trustItems.count > 1 else { return }
 
         let cancelItem = paywallConfig.trustItems[1]
-        if selectedPlan == .lifetime {
+        if selectedPlan.isLifetime {
             let item = paywallConfig.lifetimeTrustItem
             trustItemViews[1].update(
                 iconName: item.icon,

+ 237 - 0
smart_printer/Services/StoreKitCatalog.swift

@@ -0,0 +1,237 @@
+import Foundation
+import StoreKit
+
+enum PaywallPlanKind: String, CaseIterable, Sendable {
+    case weekly
+    case monthly
+    case yearly
+    case lifetime
+}
+
+struct StoreKitCatalogEntry: Sendable {
+    let productID: String
+    let displayName: String?
+    let kind: PaywallPlanKind
+    let sortOrder: Int
+}
+
+enum StoreKitCatalog {
+    static let shared: StoreKitCatalogSnapshot = StoreKitCatalogSnapshot()
+
+    static var orderedPlans: [PaywallPlan] { shared.orderedPlans }
+    static var allProductIDs: Set<String> { shared.allProductIDs }
+}
+
+struct StoreKitCatalogSnapshot: Sendable {
+    let entries: [StoreKitCatalogEntry]
+    let orderedPlans: [PaywallPlan]
+    let allProductIDs: Set<String>
+    private let entriesByProductID: [String: StoreKitCatalogEntry]
+
+    init() {
+        let loadedEntries = Self.loadEntriesFromBundle() ?? Self.fallbackEntries()
+        entries = loadedEntries
+        orderedPlans = loadedEntries.map { PaywallPlan(productID: $0.productID) }
+        allProductIDs = Set(loadedEntries.map(\.productID))
+        entriesByProductID = Dictionary(uniqueKeysWithValues: loadedEntries.map { ($0.productID, $0) })
+    }
+
+    func contains(productID: String) -> Bool {
+        allProductIDs.contains(productID)
+    }
+
+    func entry(for productID: String) -> StoreKitCatalogEntry? {
+        entriesByProductID[productID]
+    }
+
+    func kind(for productID: String) -> PaywallPlanKind? {
+        entry(for: productID)?.kind
+    }
+
+    func productID(for kind: PaywallPlanKind) -> String? {
+        entries.first(where: { $0.kind == kind })?.productID
+    }
+
+    func plan(matching storedValue: String) -> PaywallPlan? {
+        if contains(productID: storedValue) {
+            return PaywallPlan(productID: storedValue)
+        }
+        if let kind = PaywallPlanKind(rawValue: storedValue),
+           let productID = productID(for: kind) {
+            return PaywallPlan(productID: productID)
+        }
+        return nil
+    }
+
+    func defaultSelectedPlan() -> PaywallPlan {
+        if let yearlyID = productID(for: .yearly) {
+            return PaywallPlan(productID: yearlyID)
+        }
+        if let firstSubscription = entries.first(where: { $0.kind != .lifetime }) {
+            return PaywallPlan(productID: firstSubscription.productID)
+        }
+        if let first = entries.first {
+            return PaywallPlan(productID: first.productID)
+        }
+        let bundled = PaywallConfig.loadBundled()
+        return PaywallPlan(productID: bundled.plans.yearly.productID)
+    }
+
+    func preferredPlan(_ current: PaywallPlan?, _ candidate: PaywallPlan) -> PaywallPlan {
+        guard let current else { return candidate }
+        let currentTier = tier(for: current.productID)
+        let candidateTier = tier(for: candidate.productID)
+        return candidateTier >= currentTier ? candidate : current
+    }
+
+    func tier(for productID: String) -> Int {
+        entry(for: productID)?.sortOrder ?? 0
+    }
+
+    var subscriptionPlans: [PaywallPlan] {
+        orderedPlans.filter { kind(for: $0.productID) != .lifetime }
+    }
+
+    private static func loadEntriesFromBundle() -> [StoreKitCatalogEntry]? {
+        guard let url = Bundle.main.url(forResource: "Products", withExtension: "storekit"),
+              let data = try? Data(contentsOf: url),
+              let file = try? JSONDecoder().decode(StoreKitConfigurationFile.self, from: data) else {
+            return nil
+        }
+
+        var entries: [StoreKitCatalogEntry] = []
+
+        for product in file.products {
+            entries.append(
+                StoreKitCatalogEntry(
+                    productID: product.productID,
+                    displayName: product.preferredDisplayName,
+                    kind: kind(forProductID: product.productID, type: product.type),
+                    sortOrder: 1_000
+                )
+            )
+        }
+
+        for group in file.subscriptionGroups {
+            for subscription in group.subscriptions {
+                entries.append(
+                    StoreKitCatalogEntry(
+                        productID: subscription.productID,
+                        displayName: subscription.preferredDisplayName,
+                        kind: kind(
+                            forProductID: subscription.productID,
+                            subscriptionPeriod: subscription.recurringSubscriptionPeriod
+                        ),
+                        sortOrder: sortOrder(for: subscription.recurringSubscriptionPeriod)
+                    )
+                )
+            }
+        }
+
+        guard !entries.isEmpty else { return nil }
+
+        return entries.sorted { lhs, rhs in
+            if lhs.sortOrder == rhs.sortOrder {
+                return lhs.productID < rhs.productID
+            }
+            return lhs.sortOrder < rhs.sortOrder
+        }
+    }
+
+    private static func fallbackEntries() -> [StoreKitCatalogEntry] {
+        let config = PaywallConfig.loadBundled()
+        return [
+            StoreKitCatalogEntry(
+                productID: config.plans.monthly.productID,
+                displayName: config.plans.monthly.title,
+                kind: .monthly,
+                sortOrder: 20
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.yearly.productID,
+                displayName: config.plans.yearly.title,
+                kind: .yearly,
+                sortOrder: 30
+            ),
+            StoreKitCatalogEntry(
+                productID: config.plans.lifetime.productID,
+                displayName: config.plans.lifetime.title,
+                kind: .lifetime,
+                sortOrder: 1_000
+            ),
+        ]
+    }
+
+    private static func kind(
+        forProductID productID: String,
+        type: String? = nil,
+        subscriptionPeriod: String? = nil
+    ) -> PaywallPlanKind {
+        if let suffixKind = PaywallPlanKind.allCases.first(where: { productID.hasSuffix(".\($0.rawValue)") }) {
+            return suffixKind
+        }
+        if type == "NonConsumable" {
+            return .lifetime
+        }
+        switch subscriptionPeriod {
+        case "P1W": return .weekly
+        case "P1M": return .monthly
+        case "P1Y": return .yearly
+        default: return .monthly
+        }
+    }
+
+    private static func sortOrder(for subscriptionPeriod: String?) -> Int {
+        switch subscriptionPeriod {
+        case "P1W": return 10
+        case "P1M": return 20
+        case "P1Y": return 30
+        default: return 40
+        }
+    }
+}
+
+private struct StoreKitConfigurationFile: Decodable {
+    struct Product: Decodable {
+        struct Localization: Decodable {
+            let displayName: String
+            let locale: String
+        }
+
+        let productID: String
+        let type: String
+        let localizations: [Localization]?
+    }
+
+    struct SubscriptionGroup: Decodable {
+        struct Subscription: Decodable {
+            struct Localization: Decodable {
+                let displayName: String
+                let locale: String
+            }
+
+            let productID: String
+            let recurringSubscriptionPeriod: String?
+            let localizations: [Localization]?
+        }
+
+        let subscriptions: [Subscription]
+    }
+
+    let products: [Product]
+    let subscriptionGroups: [SubscriptionGroup]
+}
+
+private extension StoreKitConfigurationFile.Product {
+    var preferredDisplayName: String? {
+        localizations?.first(where: { $0.locale.hasPrefix("en") })?.displayName
+            ?? localizations?.first?.displayName
+    }
+}
+
+private extension StoreKitConfigurationFile.SubscriptionGroup.Subscription {
+    var preferredDisplayName: String? {
+        localizations?.first(where: { $0.locale.hasPrefix("en") })?.displayName
+            ?? localizations?.first?.displayName
+    }
+}

+ 1 - 1
smart_printer/UIComponents.swift

@@ -263,7 +263,7 @@ final class SidebarPremiumCardView: NSView, AppearanceRefreshable {
 
         let actionTitle: String
         if isPremium {
-            actionTitle = activePlan == .lifetime
+            actionTitle = activePlan?.isLifetime == true
                 ? sidebar.lifetimeAction
                 : sidebar.manageSubscriptionAction
         } else {