Răsfoiți Sursa

Move to production App Store IDs and externalize paywall content.

Replace MQL-DEV bundle and IAP product IDs with com.smartprinter for App Store Connect, and load paywall copy, URLs, and price templates from paywall.json with remote refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 4 săptămâni în urmă
părinte
comite
6c2ac4e8d1

+ 3 - 3
Products.storekit

@@ -25,7 +25,7 @@
           "locale" : "en_US"
         }
       ],
-      "productID" : "MQL-DEV.smart-printer.premium.lifetime",
+      "productID" : "com.smartprinter.premium.lifetime",
       "referenceName" : "Lifetime Premium",
       "type" : "NonConsumable"
     }
@@ -111,7 +111,7 @@
               "locale" : "en_US"
             }
           ],
-          "productID" : "MQL-DEV.smart-printer.premium.monthly",
+          "productID" : "com.smartprinter.premium.monthly",
           "recurringSubscriptionPeriod" : "P1M",
           "referenceName" : "Monthly Premium",
           "subscriptionGroupID" : "21502901",
@@ -144,7 +144,7 @@
               "locale" : "en_US"
             }
           ],
-          "productID" : "MQL-DEV.smart-printer.premium.yearly",
+          "productID" : "com.smartprinter.premium.yearly",
           "recurringSubscriptionPeriod" : "P1Y",
           "referenceName" : "Yearly Premium",
           "subscriptionGroupID" : "21502901",

+ 2 - 2
smart_printer.xcodeproj/project.pbxproj

@@ -275,7 +275,7 @@
 					"-framework",
 					ImageCaptureCore,
 				);
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.smart-printer";
+				PRODUCT_BUNDLE_IDENTIFIER = com.smartprinter;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -317,7 +317,7 @@
 					"-framework",
 					ImageCaptureCore,
 				);
-				PRODUCT_BUNDLE_IDENTIFIER = "MQL-DEV.smart-printer";
+				PRODUCT_BUNDLE_IDENTIFIER = com.smartprinter;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				REGISTER_APP_GROUPS = YES;
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;

+ 1 - 0
smart_printer/AppDelegate.swift

@@ -17,6 +17,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
 
     func applicationDidFinishLaunching(_ notification: Notification) {
         StoreManager.shared.start()
+        PaywallConfigService.shared.start()
         AppRatingManager.shared.start()
         resolveMainWindowController()
         configureMainWindow()

+ 15 - 0
smart_printer/AppStoreConfig.swift

@@ -0,0 +1,15 @@
+import Foundation
+
+/// Production App Store identifiers for Smart Printer.
+/// Keep these aligned with Xcode, App Store Connect, and `Products.storekit`.
+enum AppStoreConfig {
+    static let bundleIdentifier = "com.smartprinter"
+
+    enum ProductID {
+        static let monthly = "\(bundleIdentifier).premium.monthly"
+        static let yearly = "\(bundleIdentifier).premium.yearly"
+        static let lifetime = "\(bundleIdentifier).premium.lifetime"
+
+        static let all: Set<String> = [monthly, yearly, lifetime]
+    }
+}

+ 79 - 0
smart_printer/PaywallConfig.swift

@@ -0,0 +1,79 @@
+import Foundation
+
+struct PaywallConfig: Codable, Sendable {
+    struct PlanCopy: Codable, Sendable {
+        let title: String
+        let badge: String?
+        let subtitleTemplate: String
+        let trialSubtitleTemplate: String?
+        let ctaTemplate: String
+
+        init(
+            title: String,
+            badge: String? = nil,
+            subtitleTemplate: String,
+            trialSubtitleTemplate: String? = nil,
+            ctaTemplate: String
+        ) {
+            self.title = title
+            self.badge = badge
+            self.subtitleTemplate = subtitleTemplate
+            self.trialSubtitleTemplate = trialSubtitleTemplate
+            self.ctaTemplate = ctaTemplate
+        }
+    }
+
+    struct TrustItem: Codable, Sendable {
+        let icon: String
+        let title: String
+        let subtitle: String
+    }
+
+    struct Footer: Codable, Sendable {
+        let continueFree: String
+        let manageSubscription: String
+        let restorePurchase: String
+        let privacyPolicy: String
+        let termsOfService: String
+        let support: String
+    }
+
+    struct URLs: Codable, Sendable {
+        let privacy: String
+        let terms: String
+        let support: String
+        let manageSubscriptions: String
+    }
+
+    struct Plans: Codable, Sendable {
+        let monthly: PlanCopy
+        let yearly: PlanCopy
+        let lifetime: PlanCopy
+    }
+
+    let leftPanelTitle: String
+    let rightTitle: String
+    let rightSubtitle: String
+    let features: [String]
+    let trustItems: [TrustItem]
+    let plans: Plans
+    let footer: Footer
+    let urls: URLs
+    let loadingPrice: String
+    let loadingCTA: String
+}
+
+extension PaywallConfig {
+    static func loadBundled() -> PaywallConfig {
+        guard let url = Bundle.main.url(forResource: "paywall", withExtension: "json"),
+              let data = try? Data(contentsOf: url),
+              let config = try? JSONDecoder().decode(PaywallConfig.self, from: data) else {
+            fatalError("Missing or invalid paywall.json in app bundle.")
+        }
+        return config
+    }
+}
+
+extension Notification.Name {
+    static let paywallConfigDidUpdate = Notification.Name("paywallConfigDidUpdate")
+}

+ 56 - 0
smart_printer/PaywallConfigService.swift

@@ -0,0 +1,56 @@
+import Foundation
+
+@MainActor
+final class PaywallConfigService {
+    static let shared = PaywallConfigService()
+
+    private static let remoteConfigURL = URL(
+        string: "https://git.mqldevelopment.com/ahtasham.shahzad/smart_printer/-/raw/main/smart_printer/paywall.json"
+    )!
+
+    private(set) var config: PaywallConfig
+    private var hasStarted = false
+
+    private init() {
+        config = PaywallConfig.loadBundled()
+    }
+
+    func start() {
+        guard !hasStarted else { return }
+        hasStarted = true
+        Task { await refreshFromRemote() }
+    }
+
+    func refreshFromRemote() async {
+        var request = URLRequest(url: Self.remoteConfigURL)
+        request.cachePolicy = .reloadIgnoringLocalCacheData
+        request.timeoutInterval = 15
+
+        do {
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else {
+                return
+            }
+            let remoteConfig = try JSONDecoder().decode(PaywallConfig.self, from: data)
+            apply(remoteConfig)
+        } catch {
+            NSLog("Failed to refresh paywall config: \(error.localizedDescription)")
+        }
+    }
+
+    private func apply(_ newConfig: PaywallConfig) {
+        guard newConfig != config else { return }
+        config = newConfig
+        NotificationCenter.default.post(name: .paywallConfigDidUpdate, object: nil)
+    }
+}
+
+extension PaywallConfig: Equatable {
+    static func == (lhs: PaywallConfig, rhs: PaywallConfig) -> Bool {
+        guard let lhsData = try? JSONEncoder().encode(lhs),
+              let rhsData = try? JSONEncoder().encode(rhs) else {
+            return false
+        }
+        return lhsData == rhsData
+    }
+}

+ 183 - 109
smart_printer/PaywallView.swift

@@ -41,84 +41,62 @@ enum PaywallPlan: CaseIterable {
         }
     }
 
-    var title: String {
+    func planCopy(from config: PaywallConfig) -> PaywallConfig.PlanCopy {
         switch self {
-        case .monthly: "Monthly"
-        case .yearly: "Yearly"
-        case .lifetime: "Lifetime"
+        case .monthly: config.plans.monthly
+        case .yearly: config.plans.yearly
+        case .lifetime: config.plans.lifetime
         }
     }
 
-    var subtitle: String {
-        switch self {
-        case .monthly: "$4.99 / month, cancel anytime"
-        case .yearly: "$29.99 / year, cancel anytime"
-        case .lifetime: "$99.99 once, lifetime access"
-        }
+    func localizedPrice(from product: Product?, config: PaywallConfig) -> String {
+        product?.displayPrice ?? config.loadingPrice
     }
 
-    var price: String {
-        switch self {
-        case .monthly: "$4.99"
-        case .yearly: "$29.99"
-        case .lifetime: "$99.99"
-        }
-    }
+    func localizedSubtitle(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) -> String {
+        guard let product else { return config.loadingPrice }
+        let copy = planCopy(from: config)
+        let price = product.displayPrice
 
-    var ctaTitle: String {
-        switch self {
-        case .monthly: "Subscribe for $4.99 / Month"
-        case .yearly: "Subscribe for $29.99 / Year"
-        case .lifetime: "Buy Lifetime Access"
+        if self == .yearly, let introOffer, let trialTemplate = copy.trialSubtitleTemplate {
+            let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
+            return trialTemplate
+                .replacingOccurrences(of: "{trial}", with: duration)
+                .replacingOccurrences(of: "{price}", with: price)
         }
-    }
 
-    func localizedPrice(from product: Product?) -> String {
-        product?.displayPrice ?? price
+        return copy.subtitleTemplate.replacingOccurrences(of: "{price}", with: price)
     }
 
-    func localizedSubtitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
-        guard let product else { return subtitle }
+    func localizedCTATitle(
+        from product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) -> String {
+        guard let product else { return config.loadingCTA }
+        let copy = planCopy(from: config)
+        let price = product.displayPrice
 
-        switch self {
-        case .monthly:
-            return "\(product.displayPrice) / month, cancel anytime"
-        case .yearly:
-            if let introOffer {
-                let duration = SubscriptionOfferFormatting.trialDurationDescription(from: introOffer).lowercased()
-                return "Get \(duration) free, then \(product.displayPrice) / year"
-            }
-            return "\(product.displayPrice) / year, cancel anytime"
-        case .lifetime:
-            return "\(product.displayPrice) once, lifetime access"
+        if self == .yearly, let introOffer {
+            return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
         }
-    }
 
-    func localizedCTATitle(from product: Product?, introOffer: Product.SubscriptionOffer? = nil) -> String {
-        guard let product else { return ctaTitle }
-
-        switch self {
-        case .monthly:
-            return "Subscribe for \(product.displayPrice) / Month"
-        case .yearly:
-            if let introOffer {
-                return SubscriptionOfferFormatting.freeTrialCTATitle(from: introOffer)
-            }
-            return "Subscribe for \(product.displayPrice) / Year"
-        case .lifetime:
-            return "Buy Lifetime Access for \(product.displayPrice)"
-        }
+        return copy.ctaTemplate.replacingOccurrences(of: "{price}", with: price)
     }
 }
 
 // MARK: - StoreKit
 
 enum StoreProductID {
-    static let monthly = "MQL-DEV.smart-printer.premium.monthly"
-    static let yearly = "MQL-DEV.smart-printer.premium.yearly"
-    static let lifetime = "MQL-DEV.smart-printer.premium.lifetime"
+    static let monthly = AppStoreConfig.ProductID.monthly
+    static let yearly = AppStoreConfig.ProductID.yearly
+    static let lifetime = AppStoreConfig.ProductID.lifetime
 
-    static let all: Set<String> = [monthly, yearly, lifetime]
+    static let all = AppStoreConfig.ProductID.all
 }
 
 enum StoreError: LocalizedError {
@@ -265,7 +243,8 @@ final class StoreManager {
     }
 
     func showManageSubscriptions() {
-        guard let url = URL(string: "https://apps.apple.com/account/subscriptions") else { return }
+        let urlString = PaywallConfigService.shared.config.urls.manageSubscriptions
+        guard let url = URL(string: urlString) else { return }
         NSWorkspace.shared.open(url)
     }
 
@@ -577,6 +556,10 @@ private final class PaywallFeatureRow: NSView, AppearanceRefreshable {
         ])
     }
 
+    func updateText(_ text: String) {
+        label.stringValue = text
+    }
+
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
@@ -611,17 +594,18 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
         layer?.cornerRadius = 12
         layer?.masksToBounds = false
 
-        titleLabel.stringValue = plan.title
+        let config = PaywallConfigService.shared.config
+        titleLabel.stringValue = plan.planCopy(from: config).title
         titleLabel.font = AppTheme.semiboldFont(size: 15)
         titleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        subtitleLabel.stringValue = plan.subtitle
+        subtitleLabel.stringValue = config.loadingPrice
         subtitleLabel.font = AppTheme.regularFont(size: 11)
         subtitleLabel.themeLabelStyle = .secondary
         subtitleLabel.textColor = AppTheme.textSecondary
         subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        priceLabel.stringValue = plan.price
+        priceLabel.stringValue = config.loadingPrice
         priceLabel.font = AppTheme.semiboldFont(size: 15)
         priceLabel.alignment = .right
         priceLabel.translatesAutoresizingMaskIntoConstraints = false
@@ -644,9 +628,9 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
             priceLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
         ])
 
-        if plan == .lifetime {
+        if plan == .lifetime, let badgeText = plan.planCopy(from: config).badge {
             let badge = PaywallBadgeView(
-                text: "Best Value",
+                text: badgeText,
                 iconName: "star.fill",
                 background: AppTheme.paywallGold,
                 foreground: AppTheme.paywallGoldText
@@ -670,12 +654,30 @@ private final class PaywallPlanCard: NSControl, AppearanceRefreshable {
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
-    func updateDisplay(product: Product?, introOffer: Product.SubscriptionOffer? = nil) {
-        subtitleLabel.stringValue = plan.localizedSubtitle(from: product, introOffer: introOffer)
-        priceLabel.stringValue = plan.localizedPrice(from: product)
+    func updateDisplay(
+        product: Product?,
+        config: PaywallConfig,
+        introOffer: Product.SubscriptionOffer? = nil
+    ) {
+        titleLabel.stringValue = plan.planCopy(from: config).title
+        subtitleLabel.stringValue = plan.localizedSubtitle(from: product, config: config, introOffer: introOffer)
+        priceLabel.stringValue = plan.localizedPrice(from: product, config: config)
+        updateLifetimeBadge(config: config)
         updateTrialBadge(introOffer: introOffer)
     }
 
+    private func updateLifetimeBadge(config: PaywallConfig) {
+        guard plan == .lifetime else { return }
+        if let badgeText = plan.planCopy(from: config).badge {
+            if let badgeView {
+                badgeView.updateText(badgeText)
+                badgeView.isHidden = false
+            }
+        } else {
+            badgeView?.isHidden = true
+        }
+    }
+
     private func updateTrialBadge(introOffer: Product.SubscriptionOffer?) {
         guard plan == .yearly else { return }
 
@@ -851,6 +853,15 @@ private final class PaywallTrustItemView: NSView, AppearanceRefreshable {
         refreshAppearance()
     }
 
+    func update(iconName: String, title: String, subtitle: String) {
+        titleLabel.stringValue = title
+        subtitleLabel.stringValue = subtitle
+        if let image = NSImage(systemSymbolName: iconName, accessibilityDescription: nil) {
+            let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
+            icon.image = image.withSymbolConfiguration(symbolConfig)
+        }
+    }
+
     @available(*, unavailable)
     required init?(coder: NSCoder) { nil }
 
@@ -973,13 +984,20 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private var selectedPlan: PaywallPlan = .yearly
     private var planCards: [PaywallPlan: PaywallPlanCard] = [:]
     private let ctaButton = PaywallCTAButton()
-    private let continueFreePlanLink = PaywallFooterLink(title: "Continue with free plan")
-    private let manageSubscriptionLink = PaywallFooterLink(title: "Manage Subscription")
+    private let continueFreePlanLink = PaywallFooterLink(title: "")
+    private let manageSubscriptionLink = PaywallFooterLink(title: "")
+    private let restoreLink = PaywallFooterLink(title: "")
+    private let privacyLink = PaywallFooterLink(title: "")
+    private let termsLink = PaywallFooterLink(title: "")
+    private let supportLink = PaywallFooterLink(title: "")
     private let premiumCloseButton = PaywallCloseButton()
     private var primaryFooterLinkCell: NSView?
     private var leftPanelTitle: NSTextField!
     private var rightTitle: NSTextField!
     private var rightSubtitle: NSTextField!
+    private var featuresStack: NSStackView!
+    private var featureRows: [PaywallFeatureRow] = []
+    private var trustItemViews: [PaywallTrustItemView] = []
     private var trustStack: NSStackView!
     private var storeObservers: [NSObjectProtocol] = []
 
@@ -990,6 +1008,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
         layer?.cornerRadius = 0
         setup()
         observeStoreUpdates()
+        refreshConfigDisplay()
         refreshProductDisplay()
         refreshAppearance()
     }
@@ -1010,20 +1029,79 @@ final class PaywallView: NSView, AppearanceRefreshable {
             center.addObserver(forName: .premiumStatusDidChange, object: nil, queue: .main) { [weak self] _ in
                 self?.refreshProductDisplay()
             },
+            center.addObserver(forName: .paywallConfigDidUpdate, object: nil, queue: .main) { [weak self] _ in
+                self?.refreshConfigDisplay()
+                self?.refreshProductDisplay()
+            },
         ]
     }
 
+    private var paywallConfig: PaywallConfig {
+        PaywallConfigService.shared.config
+    }
+
+    private func refreshConfigDisplay() {
+        let config = paywallConfig
+
+        leftPanelTitle?.stringValue = config.leftPanelTitle
+        rightTitle?.stringValue = config.rightTitle
+        rightSubtitle?.stringValue = config.rightSubtitle
+
+        syncFeatureRows(with: config.features)
+        syncTrustItems(with: config.trustItems)
+
+        continueFreePlanLink.updateTitle(config.footer.continueFree)
+        manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
+        restoreLink.updateTitle(config.footer.restorePurchase)
+        privacyLink.updateTitle(config.footer.privacyPolicy)
+        termsLink.updateTitle(config.footer.termsOfService)
+        supportLink.updateTitle(config.footer.support)
+    }
+
+    private func syncFeatureRows(with features: [String]) {
+        guard let featuresStack else { return }
+
+        while featureRows.count < features.count {
+            let row = PaywallFeatureRow(text: "")
+            featureRows.append(row)
+            featuresStack.addArrangedSubview(row)
+        }
+
+        while featureRows.count > features.count {
+            let row = featureRows.removeLast()
+            featuresStack.removeArrangedSubview(row)
+            row.removeFromSuperview()
+        }
+
+        for (index, text) in features.enumerated() {
+            featureRows[index].updateText(text)
+        }
+    }
+
+    private func syncTrustItems(with items: [PaywallConfig.TrustItem]) {
+        for (index, item) in items.enumerated() where index < trustItemViews.count {
+            trustItemViews[index].update(
+                iconName: item.icon,
+                title: item.title,
+                subtitle: item.subtitle
+            )
+        }
+    }
+
     private func refreshProductDisplay() {
         let store = StoreManager.shared
+        let config = paywallConfig
         for (plan, card) in planCards {
             let product = store.product(for: plan)
             card.updateDisplay(
                 product: product,
+                config: config,
                 introOffer: store.eligibleIntroOffer(for: plan)
             )
         }
         ctaButton.title = selectedPlan.localizedCTATitle(
             from: store.product(for: selectedPlan),
+            config: config,
             introOffer: store.eligibleIntroOffer(for: selectedPlan)
         )
         refreshPurchaseState()
@@ -1097,9 +1175,10 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makeLeftPanel() -> NSView {
         let panel = PaywallLeftPanelView()
         panel.translatesAutoresizingMaskIntoConstraints = false
+        let config = paywallConfig
 
         let title = NSTextField.themeLabel(
-            "Unlock Your Full\nPrinting Potential",
+            config.leftPanelTitle,
             style: .primary,
             font: AppTheme.semiboldFont(size: 22)
         )
@@ -1112,17 +1191,12 @@ final class PaywallView: NSView, AppearanceRefreshable {
         featuresStack.spacing = 6
         featuresStack.alignment = .leading
         featuresStack.translatesAutoresizingMaskIntoConstraints = false
+        self.featuresStack = featuresStack
 
-        let features = [
-            "Unlimited high-quality scans",
-            "Advanced OCR technology",
-            "Draw & print canvas",
-            "Ad-free experience",
-            "Priority support",
-            "Secure storage",
-        ]
-        for feature in features {
-            featuresStack.addArrangedSubview(PaywallFeatureRow(text: feature))
+        for feature in config.features {
+            let row = PaywallFeatureRow(text: feature)
+            featureRows.append(row)
+            featuresStack.addArrangedSubview(row)
         }
 
         panel.addSubview(title)
@@ -1144,14 +1218,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makeRightPanel() -> NSView {
         let panel = NSView()
         panel.translatesAutoresizingMaskIntoConstraints = false
+        let config = paywallConfig
 
-        let title = NSTextField.themeLabel("Go Premium", style: .primary, font: AppTheme.semiboldFont(size: 26))
+        let title = NSTextField.themeLabel(
+            config.rightTitle,
+            style: .primary,
+            font: AppTheme.semiboldFont(size: 26)
+        )
         title.alignment = .center
         title.translatesAutoresizingMaskIntoConstraints = false
         rightTitle = title
 
         let subtitle = NSTextField.themeLabel(
-            "Experience professional quality printing and scanning without limits.",
+            config.rightSubtitle,
             style: .secondary,
             font: AppTheme.regularFont(size: 13)
         )
@@ -1175,6 +1254,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
 
         ctaButton.title = selectedPlan.localizedCTATitle(
             from: StoreManager.shared.product(for: selectedPlan),
+            config: config,
             introOffer: StoreManager.shared.eligibleIntroOffer(for: selectedPlan)
         )
         ctaButton.target = self
@@ -1223,28 +1303,16 @@ final class PaywallView: NSView, AppearanceRefreshable {
     }
 
     private func makeTrustRow() -> NSView {
-        let securePayments = PaywallTrustItemView(
-            iconName: "shield.fill",
-            title: "Secure Payments",
-            subtitle: "Your payment is 100% secure"
-        )
-        let cancelAnytime = PaywallTrustItemView(
-            iconName: "arrow.counterclockwise",
-            title: "Cancel Anytime",
-            subtitle: "No commitment, cancel anytime."
-        )
-        let support = PaywallTrustItemView(
-            iconName: "headphones",
-            title: "24/7 Support",
-            subtitle: "We're here to help you anytime."
-        )
-        let privacyFirst = PaywallTrustItemView(
-            iconName: "lock.fill",
-            title: "Privacy First",
-            subtitle: "Your data is safe with us."
-        )
+        let config = paywallConfig
+        trustItemViews = config.trustItems.map { item in
+            PaywallTrustItemView(
+                iconName: item.icon,
+                title: item.title,
+                subtitle: item.subtitle
+            )
+        }
 
-        let trustStack = NSStackView(views: [securePayments, cancelAnytime, support, privacyFirst])
+        let trustStack = NSStackView(views: trustItemViews)
         trustStack.orientation = .horizontal
         trustStack.distribution = .fillEqually
         trustStack.spacing = 16
@@ -1264,9 +1332,13 @@ final class PaywallView: NSView, AppearanceRefreshable {
     private func makeFooterLinks() -> NSView {
         let container = NSView()
         container.translatesAutoresizingMaskIntoConstraints = false
+        let config = paywallConfig
 
+        continueFreePlanLink.updateTitle(config.footer.continueFree)
         continueFreePlanLink.target = self
         continueFreePlanLink.action = #selector(continueWithFreePlanTapped)
+
+        manageSubscriptionLink.updateTitle(config.footer.manageSubscription)
         manageSubscriptionLink.target = self
         manageSubscriptionLink.action = #selector(manageSubscriptionTapped)
 
@@ -1274,19 +1346,19 @@ final class PaywallView: NSView, AppearanceRefreshable {
         primaryFooterLinkCell = primaryCell
         refreshPrimaryFooterLink()
 
-        let restoreLink = PaywallFooterLink(title: "Restore Purchase")
+        restoreLink.updateTitle(config.footer.restorePurchase)
         restoreLink.target = self
         restoreLink.action = #selector(restoreTapped)
 
-        let privacyLink = PaywallFooterLink(title: "Privacy Policy")
+        privacyLink.updateTitle(config.footer.privacyPolicy)
         privacyLink.target = self
         privacyLink.action = #selector(privacyPolicyTapped)
 
-        let termsLink = PaywallFooterLink(title: "Terms of Service")
+        termsLink.updateTitle(config.footer.termsOfService)
         termsLink.target = self
         termsLink.action = #selector(termsOfServiceTapped)
 
-        let supportLink = PaywallFooterLink(title: "Support")
+        supportLink.updateTitle(config.footer.support)
         supportLink.target = self
         supportLink.action = #selector(supportTapped)
 
@@ -1355,6 +1427,7 @@ final class PaywallView: NSView, AppearanceRefreshable {
         }
         ctaButton.title = plan.localizedCTATitle(
             from: StoreManager.shared.product(for: plan),
+            config: paywallConfig,
             introOffer: StoreManager.shared.eligibleIntroOffer(for: plan)
         )
     }
@@ -1411,15 +1484,15 @@ final class PaywallView: NSView, AppearanceRefreshable {
     }
 
     @objc private func privacyPolicyTapped() {
-        openExternalLink("https://sites.google.com/view/smartprinterappmacos/privacy-policy")
+        openExternalLink(paywallConfig.urls.privacy)
     }
 
     @objc private func termsOfServiceTapped() {
-        openExternalLink("https://sites.google.com/view/smartprinterappmacos/terms-and-condition")
+        openExternalLink(paywallConfig.urls.terms)
     }
 
     @objc private func supportTapped() {
-        openExternalLink("https://sites.google.com/view/smartprinterappmacos/get-support")
+        openExternalLink(paywallConfig.urls.support)
     }
 
     private func openExternalLink(_ urlString: String) {
@@ -1535,6 +1608,7 @@ final class PaywallOverlayView: NSView, AppearanceRefreshable {
                 await StoreManager.shared.loadProducts()
             }
             await StoreManager.shared.refreshPremiumStatus()
+            await PaywallConfigService.shared.refreshFromRemote()
             paywallView.refreshStoreState()
             alphaValue = 1
         }

+ 70 - 0
smart_printer/paywall.json

@@ -0,0 +1,70 @@
+{
+  "leftPanelTitle": "Unlock Your Full\nPrinting Potential",
+  "rightTitle": "Go Premium",
+  "rightSubtitle": "Experience professional quality printing and scanning without limits.",
+  "features": [
+    "Unlimited high-quality scans",
+    "Advanced OCR technology",
+    "Draw & print canvas",
+    "Ad-free experience",
+    "Priority support",
+    "Secure storage"
+  ],
+  "trustItems": [
+    {
+      "icon": "shield.fill",
+      "title": "Secure Payments",
+      "subtitle": "Your payment is 100% secure"
+    },
+    {
+      "icon": "arrow.counterclockwise",
+      "title": "Cancel Anytime",
+      "subtitle": "No commitment, cancel anytime."
+    },
+    {
+      "icon": "headphones",
+      "title": "24/7 Support",
+      "subtitle": "We're here to help you anytime."
+    },
+    {
+      "icon": "lock.fill",
+      "title": "Privacy First",
+      "subtitle": "Your data is safe with us."
+    }
+  ],
+  "plans": {
+    "monthly": {
+      "title": "Monthly",
+      "subtitleTemplate": "{price} / month, cancel anytime",
+      "ctaTemplate": "Subscribe for {price} / Month"
+    },
+    "yearly": {
+      "title": "Yearly",
+      "subtitleTemplate": "{price} / year, cancel anytime",
+      "trialSubtitleTemplate": "Get {trial} free, then {price} / year",
+      "ctaTemplate": "Subscribe for {price} / Year"
+    },
+    "lifetime": {
+      "title": "Lifetime",
+      "badge": "Best Value",
+      "subtitleTemplate": "{price} once, lifetime access",
+      "ctaTemplate": "Buy Lifetime Access for {price}"
+    }
+  },
+  "footer": {
+    "continueFree": "Continue with free plan",
+    "manageSubscription": "Manage Subscription",
+    "restorePurchase": "Restore Purchase",
+    "privacyPolicy": "Privacy Policy",
+    "termsOfService": "Terms of Service",
+    "support": "Support"
+  },
+  "urls": {
+    "privacy": "https://sites.google.com/view/smartprinterappmacos/privacy-policy",
+    "terms": "https://sites.google.com/view/smartprinterappmacos/terms-and-condition",
+    "support": "https://sites.google.com/view/smartprinterappmacos/get-support",
+    "manageSubscriptions": "https://apps.apple.com/account/subscriptions"
+  },
+  "loadingPrice": "—",
+  "loadingCTA": "Loading plans…"
+}