summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-22 12:39:23 -0500
committerChristian Cleberg <[email protected]>2026-04-22 12:39:23 -0500
commitdcf4135ec376d357b8fafc0101a75b674e76329a (patch)
tree498e0782b34778b6f6d871d2adc230437fe77904
parent37b02e5ee360d61751951b77b7b556165a3af1fc (diff)
downloaddomain-dig-dcf4135ec376d357b8fafc0101a75b674e76329a.tar.gz
domain-dig-dcf4135ec376d357b8fafc0101a75b674e76329a.tar.bz2
domain-dig-dcf4135ec376d357b8fafc0101a75b674e76329a.zip
feat(v3​.1​.0): add ​Store​Kit integration and ​Pro tier
• implement Purchase​Service using StoreKit 2 • activate feature tiers based on entitlement • add minimal paywall and restore flow • enforce free-tier limits with upgrade paths • integrate Pro status into settings • ensure clean and non-intrusive monetization flow
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift114
-rw-r--r--DomainDig/DomainDigApp.swift5
-rw-r--r--DomainDig/DomainViewModel.swift23
-rw-r--r--DomainDig/FeatureAccessService.swift96
-rw-r--r--DomainDig/PaywallView.swift123
-rw-r--r--DomainDig/PurchaseService.swift245
-rw-r--r--DomainDig/RootTabView.swift25
-rw-r--r--DomainDig/WatchlistView.swift3
-rw-r--r--DomainDig/WorkflowsView.swift53
10 files changed, 586 insertions, 103 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift
index 1a6861a..d9f3fc7 100644
--- a/DomainDig/AppVersion.swift
+++ b/DomainDig/AppVersion.swift
@@ -2,6 +2,6 @@ import Foundation
enum AppVersion {
static var current: String {
- "3.0.0"
+ "3.1.0"
}
}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 9802f77..e6e7a41 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable {
struct ContentView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
+ @State private var purchaseService = PurchaseService.shared
@State private var navigationPath = NavigationPath()
@FocusState private var domainFieldFocused: Bool
@State private var customPortInput = ""
@State private var customPortsExpanded = false
@State private var trackingNoteDraft = ""
@State private var editingTrackedDomain: TrackedDomain?
- @State private var showTrackLimitAlert = false
- @State private var featureGateMessage: String?
@State private var inputMode: LookupInputMode = .single
@State private var collapsedSections: Set<ResultSection> = [.network]
@State private var showingCurrentDomainWorkflowSheet = false
@State private var showingBatchWorkflowSheet = false
var body: some View {
+ let _ = purchaseService.currentTier
+
NavigationStack(path: $navigationPath) {
ScrollView(.vertical) {
VStack(spacing: 0) {
@@ -84,9 +85,7 @@ struct ContentView: View {
workflows: viewModel.currentDomainWorkflows,
trackingLimitMessage: viewModel.trackingLimitMessage,
onTrack: {
- if !viewModel.trackCurrentDomain() {
- showTrackLimitAlert = true
- }
+ _ = viewModel.trackCurrentDomain()
},
onTogglePinned: {
guard let trackedDomain = viewModel.currentTrackedDomain else { return }
@@ -248,23 +247,6 @@ struct ContentView: View {
navigationPath = NavigationPath()
domainFieldFocused = false
}
- .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
- Button("OK", role: .cancel) {}
- } message: {
- Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.")
- }
- .alert("Feature unavailable", isPresented: Binding(
- get: { featureGateMessage != nil },
- set: { newValue in
- if !newValue {
- featureGateMessage = nil
- }
- }
- )) {
- Button("OK", role: .cancel) {}
- } message: {
- Text(featureGateMessage ?? "")
- }
.sheet(item: $editingTrackedDomain) { trackedDomain in
NavigationStack {
Form {
@@ -346,6 +328,12 @@ struct ContentView: View {
.font(appDensity.font(.caption))
.foregroundStyle(.secondary)
+ if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
+ Text(batchAllowanceSummary)
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+
TextField(
"example.com\napple.com, openai.com",
text: $viewModel.bulkInput,
@@ -391,25 +379,25 @@ struct ContentView: View {
Menu {
if !viewModel.isCurrentDomainTracked {
Button("Track this domain") {
- if !viewModel.trackCurrentDomain() {
- showTrackLimitAlert = true
- }
+ _ = viewModel.trackCurrentDomain()
}
}
Button("Add to workflow") {
- if FeatureAccessService.hasAccess(to: .workflows) {
- showingCurrentDomainWorkflowSheet = true
- } else {
- featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
- }
+ showingCurrentDomainWorkflowSheet = true
}
- Button("Copy report JSON") {
- guard let json = viewModel.exportJSONString() else { return }
- AppClipboard.copy(json)
- AppHaptics.copy()
+ if FeatureAccessService.hasAccess(to: .advancedExports) {
+ Button("Copy report JSON") {
+ guard let json = viewModel.exportJSONString() else { return }
+ AppClipboard.copy(json)
+ AppHaptics.copy()
+ }
+ } else {
+ Button("Copy report JSON") {
+ viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
+ }
}
Button("Export report") {
- shareSingleResults(format: .json)
+ shareSingleResults(format: .text)
}
} label: {
Image(systemName: "bolt.circle")
@@ -463,11 +451,7 @@ struct ContentView: View {
if !viewModel.currentBatchResultEntries.isEmpty {
Menu {
Button("Add to Workflow") {
- if FeatureAccessService.hasAccess(to: .workflows) {
- showingBatchWorkflowSheet = true
- } else {
- featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
- }
+ showingBatchWorkflowSheet = true
}
Divider()
Button("Export Batch TXT") {
@@ -2165,6 +2149,7 @@ private extension String {
struct SettingsView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
+ @State private var purchaseService = PurchaseService.shared
@AppStorage(DNSResolverOption.userDefaultsKey)
private var storedResolverURL = DNSResolverOption.defaultURLString
@AppStorage(AppDensity.userDefaultsKey)
@@ -2185,6 +2170,8 @@ struct SettingsView: View {
}
var body: some View {
+ let _ = purchaseService.currentTier
+
Form {
Section("Display") {
Picker("Density", selection: $storedDensity) {
@@ -2233,28 +2220,51 @@ struct SettingsView: View {
}
}
- Section("Features") {
- LabeledContent("Tier", value: FeatureAccessService.currentTier.title)
+ Section("Pro") {
+ LabeledContent("Status", value: purchaseService.currentTier.title)
- if FeatureAccessService.enabledFeatureLabels().isEmpty {
- Text("No features enabled.")
- .font(appDensity.font(.caption, design: .default))
- .foregroundStyle(.secondary)
+ if purchaseService.currentTier == .free {
+ Button("Upgrade to Pro") {
+ viewModel.isPaywallPresented = true
+ }
} else {
- ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
- Text(label)
+ Button("Manage Subscription") {
+ Task {
+ await purchaseService.manageSubscription()
+ }
}
}
- Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.")
- .font(appDensity.font(.caption, design: .default))
- .foregroundStyle(.secondary)
+ Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
+ Task {
+ await purchaseService.restorePurchases()
+ }
+ }
+ .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
+
+ if let statusMessage = purchaseService.statusMessage {
+ Text(statusMessage)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ }
+
+ if let errorMessage = purchaseService.errorMessage {
+ Text(errorMessage)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.red)
+ }
+ }
+
+ Section("Features") {
+ ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
+ Text(label)
+ }
}
Section("About") {
LabeledContent("Version", value: appVersion)
LabeledContent("Storage", value: "Local-only")
- LabeledContent("Report Schema", value: "3.0.0")
+ LabeledContent("Report Schema", value: "3.1.0")
}
}
.navigationTitle("Settings")
diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift
index 5eeed32..ae81654 100644
--- a/DomainDig/DomainDigApp.swift
+++ b/DomainDig/DomainDigApp.swift
@@ -11,6 +11,7 @@ import SwiftUI
struct DomainDigApp: App {
@AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
@State private var viewModel = DomainViewModel()
+ @State private var purchaseService = PurchaseService.shared
init() {
LocalNotificationService.shared.configureForegroundPresentation()
@@ -20,6 +21,10 @@ struct DomainDigApp: App {
WindowGroup {
RootTabView(viewModel: viewModel)
.environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
+ .task {
+ let _ = purchaseService.currentTier
+ await purchaseService.refreshEntitlements()
+ }
}
}
}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 5288eaa..6ec7e64 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -226,6 +226,8 @@ final class DomainViewModel {
var watchlistSearchText = ""
var watchlistFilter: WatchlistFilterOption = .all
var watchlistSortOption: WatchlistSortOption = .pinned
+ var upgradePrompt: UpgradePromptContext?
+ var isPaywallPresented = false
var trimmedDomain: String {
domain
@@ -578,6 +580,7 @@ final class DomainViewModel {
}
guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
+ upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
return false
}
@@ -737,12 +740,18 @@ final class DomainViewModel {
func runBulkLookup() {
let domains = parsedDomains(from: bulkInput)
guard !domains.isEmpty else { return }
- guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return }
+ guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
+ upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
+ return
+ }
startBatchLookup(domains: domains, source: .manual)
}
func refreshAllTrackedDomains() {
- guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return }
+ guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
+ upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
+ return
+ }
startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
}
@@ -791,7 +800,10 @@ final class DomainViewModel {
let normalizedDomains = normalizedDomains(domains)
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
- guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil }
+ guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
+ upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
+ return nil
+ }
let workflow = DomainWorkflow(
name: trimmedName,
@@ -855,7 +867,10 @@ final class DomainViewModel {
func runWorkflow(_ workflow: DomainWorkflow) {
guard !workflow.domains.isEmpty else { return }
- guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return }
+ guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
+ upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
+ return
+ }
startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
}
diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift
index 5f380bb..d23f348 100644
--- a/DomainDig/FeatureAccessService.swift
+++ b/DomainDig/FeatureAccessService.swift
@@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable {
let batchSizeLimit: Int?
}
+struct UpgradePromptContext: Identifiable, Equatable {
+ let id = UUID()
+ let title: String
+ let message: String
+ let capability: FeatureCapability
+}
+
enum FeatureAccessService {
- static let currentTier: FeatureTier = .free
+ private static let effectivelyUnlimitedTrackedDomains = 5_000
+
+ static var currentTier: FeatureTier {
+ PurchaseService.cachedTier
+ }
static var entitlements: FeatureEntitlements {
switch currentTier {
case .free:
return FeatureEntitlements(
tier: .free,
- capabilities: [.singleLookup, .basicHistory, .limitedTracking],
- trackedDomainLimit: 3,
- workflowLimit: 0,
- batchSizeLimit: 0
+ capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations],
+ trackedDomainLimit: 5,
+ workflowLimit: 1,
+ batchSizeLimit: 10
)
case .pro:
return FeatureEntitlements(
tier: .pro,
capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
- trackedDomainLimit: 250,
- workflowLimit: 50,
- batchSizeLimit: 100
+ trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
+ workflowLimit: nil,
+ batchSizeLimit: nil
)
case .dataPlus:
return FeatureEntitlements(
tier: .dataPlus,
capabilities: Set(FeatureCapability.allCases),
- trackedDomainLimit: 1_000,
- workflowLimit: 200,
- batchSizeLimit: 250
+ trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
+ workflowLimit: nil,
+ batchSizeLimit: nil
)
}
}
@@ -106,9 +117,10 @@ enum FeatureAccessService {
static func trackedDomainLimitMessage(currentCount: Int) -> String? {
guard currentTier == .free else { return nil }
- return currentCount >= entitlements.trackedDomainLimit
- ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
- : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
+ if currentCount >= entitlements.trackedDomainLimit {
+ return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro."
+ }
+ return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
}
static func canCreateWorkflow(currentCount: Int) -> Bool {
@@ -137,19 +149,61 @@ enum FeatureAccessService {
}
static func workflowLimitMessage(currentCount: Int) -> String? {
- guard hasAccess(to: .workflows) else {
- return upgradeMessage(for: .workflows)
- }
guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
- return "Workflow limit reached."
+ return "Free includes up to \(limit) workflow."
}
static func batchLimitMessage(domainCount: Int) -> String? {
- guard hasAccess(to: .batchOperations) else {
- return upgradeMessage(for: .batchOperations)
+ guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
+ return "Free runs batches up to \(limit) domains."
+ }
+
+ static func workflowAllowanceSummary(currentCount: Int) -> String? {
+ guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil }
+ if currentCount >= limit {
+ return "Free includes up to \(limit) workflow. Available in Pro."
}
+ return "Free includes up to \(limit) workflow."
+ }
+
+ static func batchAllowanceSummary() -> String? {
+ guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil }
+ return "Free runs batches up to \(limit) domains."
+ }
+
+ static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? {
+ guard currentCount >= entitlements.trackedDomainLimit else { return nil }
+ return UpgradePromptContext(
+ title: "Available in Pro",
+ message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.",
+ capability: .limitedTracking
+ )
+ }
+
+ static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? {
+ guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
+ return UpgradePromptContext(
+ title: "Available in Pro",
+ message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.",
+ capability: .workflows
+ )
+ }
+
+ static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? {
guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
- return "Batch limit is \(limit) domains on \(currentTier.title)."
+ return UpgradePromptContext(
+ title: "Available in Pro",
+ message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.",
+ capability: .batchOperations
+ )
+ }
+
+ static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
+ UpgradePromptContext(
+ title: "Available in Pro",
+ message: upgradeMessage(for: capability),
+ capability: capability
+ )
}
static func enabledFeatureLabels() -> [String] {
diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift
new file mode 100644
index 0000000..a21448f
--- /dev/null
+++ b/DomainDig/PaywallView.swift
@@ -0,0 +1,123 @@
+import StoreKit
+import SwiftUI
+
+struct PaywallView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.appDensity) private var appDensity
+ @State private var purchaseService = PurchaseService.shared
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
+ .font(appDensity.font(.body, design: .default))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Section("What Pro Unlocks") {
+ featureRow("Unlimited tracked domains")
+ featureRow("Workflows")
+ featureRow("Larger batch sizes")
+ featureRow("Advanced exports")
+ }
+
+ Section("Subscription") {
+ if purchaseService.isLoadingProducts {
+ ProgressView("Loading pricing…")
+ } else if purchaseService.products.isEmpty {
+ Text("Pricing is unavailable right now.")
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+
+ Button("Retry") {
+ Task {
+ await purchaseService.refreshProducts()
+ }
+ }
+ } else {
+ ForEach(purchaseService.products, id: \.id) { product in
+ Button {
+ Task {
+ await purchaseService.purchase(product)
+ }
+ } label: {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(subscriptionTitle(for: product))
+ .font(appDensity.font(.headline, design: .default, weight: .semibold))
+ .foregroundStyle(.primary)
+ Text(product.displayPrice)
+ .font(appDensity.font(.callout, design: .default))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
+ }
+ }
+
+ if let statusMessage = purchaseService.statusMessage {
+ Text(statusMessage)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.secondary)
+ }
+
+ if let errorMessage = purchaseService.errorMessage {
+ Text(errorMessage)
+ .font(appDensity.font(.caption, design: .default))
+ .foregroundStyle(.red)
+ }
+ }
+
+ Section("Account") {
+ Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
+ Task {
+ await purchaseService.restorePurchases()
+ }
+ }
+ .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
+
+ if purchaseService.hasProAccess {
+ Button("Manage Subscription") {
+ Task {
+ await purchaseService.manageSubscription()
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("DomainDig Pro")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ }
+ .task {
+ await purchaseService.refreshProducts()
+ await purchaseService.refreshEntitlements()
+ }
+ .onDisappear {
+ purchaseService.clearMessages()
+ }
+ }
+
+ private func featureRow(_ title: String) -> some View {
+ Text(title)
+ .font(appDensity.font(.body, design: .default))
+ }
+
+ private func subscriptionTitle(for product: Product) -> String {
+ switch product.id {
+ case PurchaseService.monthlyProductID:
+ return "Pro Monthly"
+ case PurchaseService.yearlyProductID:
+ return "Pro Yearly"
+ default:
+ return product.displayName
+ }
+ }
+}
diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift
new file mode 100644
index 0000000..3d19165
--- /dev/null
+++ b/DomainDig/PurchaseService.swift
@@ -0,0 +1,245 @@
+import Foundation
+import StoreKit
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+@MainActor
+@Observable
+final class PurchaseService {
+ struct CachedEntitlement: Codable {
+ let tier: FeatureTier
+ let activeProductID: String?
+ let updatedAt: Date
+ }
+
+ static let shared = PurchaseService()
+ static let monthlyProductID = "domaindig.pro.monthly"
+ static let yearlyProductID = "domaindig.pro.yearly"
+ static let productIDs = [monthlyProductID, yearlyProductID]
+
+ private static let entitlementCacheKey = "purchase.cachedEntitlement"
+
+ static var cachedEntitlement: CachedEntitlement? {
+ guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
+ return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
+ }
+
+ static var cachedTier: FeatureTier {
+ cachedEntitlement?.tier ?? .free
+ }
+
+ var products: [Product] = []
+ var currentTier: FeatureTier
+ var activeProductID: String?
+ var isLoadingProducts = false
+ var isPurchasing = false
+ var isRestoring = false
+ var statusMessage: String?
+ var errorMessage: String?
+
+ private var updatesTask: Task<Void, Never>?
+
+ private init() {
+ currentTier = Self.cachedTier
+ activeProductID = Self.cachedEntitlement?.activeProductID
+ updatesTask = observeTransactionUpdates()
+ Task {
+ await refreshProducts()
+ await refreshEntitlements()
+ }
+ }
+
+ var hasProAccess: Bool {
+ currentTier == .pro
+ }
+
+ func refreshProducts() async {
+ isLoadingProducts = true
+ errorMessage = nil
+
+ do {
+ let fetchedProducts = try await Product.products(for: Self.productIDs)
+ products = fetchedProducts.sorted { lhs, rhs in
+ productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
+ }
+ } catch {
+ products = []
+ errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
+ }
+
+ isLoadingProducts = false
+ }
+
+ func refreshEntitlements() async {
+ var activeTransactions: [Transaction] = []
+
+ for await result in Transaction.currentEntitlements {
+ guard case .verified(let transaction) = result else {
+ continue
+ }
+ guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+ continue
+ }
+ activeTransactions.append(transaction)
+ }
+
+ let activeProductID = activeTransactions
+ .sorted { $0.purchaseDate > $1.purchaseDate }
+ .first?
+ .productID
+
+ self.activeProductID = activeProductID
+ currentTier = activeProductID == nil ? .free : .pro
+ persistCurrentEntitlement()
+ }
+
+ func purchase(_ product: Product) async {
+ isPurchasing = true
+ statusMessage = nil
+ errorMessage = nil
+
+ do {
+ let result = try await product.purchase()
+
+ switch result {
+ case .success(let verification):
+ let transaction = try verifiedTransaction(from: verification)
+ apply(transaction: transaction)
+ await transaction.finish()
+ await refreshEntitlements()
+ statusMessage = "Pro is active."
+ case .userCancelled:
+ break
+ case .pending:
+ statusMessage = "Purchase is pending approval."
+ @unknown default:
+ errorMessage = "The purchase could not be completed."
+ }
+ } catch {
+ errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
+ }
+
+ isPurchasing = false
+ }
+
+ func restorePurchases() async {
+ isRestoring = true
+ statusMessage = nil
+ errorMessage = nil
+
+ do {
+ try await AppStore.sync()
+ await refreshEntitlements()
+ statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
+ } catch {
+ errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
+ }
+
+ isRestoring = false
+ }
+
+ func manageSubscription() async {
+ errorMessage = nil
+
+ #if canImport(UIKit)
+ if ProcessInfo.processInfo.isiOSAppOnMac {
+ errorMessage = "Manage Subscription is not available on this device."
+ return
+ }
+
+ guard let scene = UIApplication.shared.connectedScenes
+ .compactMap({ $0 as? UIWindowScene })
+ .first(where: { $0.activationState == .foregroundActive }) else {
+ errorMessage = "Manage Subscription is not available right now."
+ return
+ }
+
+ do {
+ try await AppStore.showManageSubscriptions(in: scene)
+ } catch {
+ errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
+ }
+ #else
+ errorMessage = "Manage Subscription is not available on this platform."
+ #endif
+ }
+
+ func clearMessages() {
+ statusMessage = nil
+ errorMessage = nil
+ }
+
+ private func apply(transaction: Transaction) {
+ guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
+ return
+ }
+
+ activeProductID = transaction.productID
+ currentTier = .pro
+ persistCurrentEntitlement()
+ }
+
+ private func observeTransactionUpdates() -> Task<Void, Never> {
+ Task.detached(priority: .background) { [weak self] in
+ for await result in Transaction.updates {
+ guard let self else { return }
+ await self.handleTransactionUpdate(result)
+ }
+ }
+ }
+
+ private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
+ guard case .verified(let transaction) = result else { return }
+ apply(transaction: transaction)
+ await transaction.finish()
+ await refreshEntitlements()
+ }
+
+ private func persistCurrentEntitlement() {
+ let cachedEntitlement = CachedEntitlement(
+ tier: currentTier,
+ activeProductID: activeProductID,
+ updatedAt: Date()
+ )
+
+ if let data = try? JSONEncoder().encode(cachedEntitlement) {
+ UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
+ }
+ }
+
+ private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
+ switch result {
+ case .verified(let transaction):
+ return transaction
+ case .unverified:
+ throw StoreKitError.notEntitled
+ }
+ }
+
+ private func productSortIndex(for productID: String) -> Int {
+ switch productID {
+ case Self.monthlyProductID:
+ return 0
+ case Self.yearlyProductID:
+ return 1
+ default:
+ return Int.max
+ }
+ }
+
+ private func storeMessage(for error: Error, fallback: String) -> String {
+ if let storeKitError = error as? StoreKitError {
+ switch storeKitError {
+ case .networkError:
+ return "The App Store is offline right now."
+ default:
+ return fallback
+ }
+ }
+
+ let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
+ return message.isEmpty ? fallback : message
+ }
+}
diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift
index 19ea444..2d2b8ed 100644
--- a/DomainDig/RootTabView.swift
+++ b/DomainDig/RootTabView.swift
@@ -2,8 +2,11 @@ import SwiftUI
struct RootTabView: View {
@Bindable var viewModel: DomainViewModel
+ @State private var purchaseService = PurchaseService.shared
var body: some View {
+ let _ = purchaseService.currentTier
+
TabView {
ContentView(viewModel: viewModel)
.tabItem {
@@ -38,5 +41,27 @@ struct RootTabView: View {
Label("Settings", systemImage: "gearshape")
}
}
+ .sheet(isPresented: Binding(
+ get: { viewModel.isPaywallPresented },
+ set: { viewModel.isPaywallPresented = $0 }
+ )) {
+ PaywallView()
+ }
+ .alert(item: Binding(
+ get: { viewModel.upgradePrompt },
+ set: { viewModel.upgradePrompt = $0 }
+ )) { prompt in
+ Alert(
+ title: Text(prompt.title),
+ message: Text(prompt.message),
+ primaryButton: .default(Text("Open Paywall")) {
+ viewModel.upgradePrompt = nil
+ viewModel.isPaywallPresented = true
+ },
+ secondaryButton: .cancel(Text("Continue")) {
+ viewModel.upgradePrompt = nil
+ }
+ )
+ }
}
}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index fcb5a16..b4e5011 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -4,6 +4,7 @@ struct WatchlistView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
@Environment(\.dismiss) private var dismiss
+ @State private var purchaseService = PurchaseService.shared
@State private var showWorkflowAddSheet = false
private var pinnedDomains: [TrackedDomain] {
@@ -15,6 +16,8 @@ struct WatchlistView: View {
}
var body: some View {
+ let _ = purchaseService.currentTier
+
List {
if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
Section("Refresh Progress") {
diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift
index 9d99b02..5025a0b 100644
--- a/DomainDig/WorkflowsView.swift
+++ b/DomainDig/WorkflowsView.swift
@@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
struct WorkflowsView: View {
@Environment(\.appDensity) private var appDensity
@Bindable var viewModel: DomainViewModel
+ @State private var purchaseService = PurchaseService.shared
@State private var showingCreateWorkflow = false
var body: some View {
+ let _ = purchaseService.currentTier
+
List {
- if FeatureAccessService.hasAccess(to: .workflows) {
- workflowContent
- } else {
+ if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
Section {
- EmptyStateCardView(
- title: "Workflows",
- message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.",
- suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
- systemImage: "square.stack.3d.down.right"
- )
+ Text(limitMessage)
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
}
.listRowBackground(Color(.systemGray6).opacity(0.5))
}
+
+ workflowContent
}
.scrollContentBackground(.hidden)
.background(Color.black)
@@ -36,17 +36,15 @@ struct WorkflowsView: View {
}
.navigationTitle("Workflows")
.toolbar {
- if FeatureAccessService.hasAccess(to: .workflows) {
- ToolbarItemGroup(placement: .topBarTrailing) {
- Button {
- showingCreateWorkflow = true
- } label: {
- Image(systemName: "plus.circle")
- }
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Button {
+ showingCreateWorkflow = true
+ } label: {
+ Image(systemName: "plus.circle")
+ }
- if !viewModel.workflows.isEmpty {
- EditButton()
- }
+ if !viewModel.workflows.isEmpty {
+ EditButton()
}
}
}
@@ -429,10 +427,13 @@ struct WorkflowComposerView: View {
private func saveWorkflow() {
if let workflow {
viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
- } else {
- _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes)
+ dismiss()
+ return
+ }
+
+ if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
+ dismiss()
}
- dismiss()
}
}
@@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View {
case .existing:
guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
viewModel.addDomains(selectedDomainList, to: workflow)
+ dismiss()
case .new:
- _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes)
+ guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
+ return
+ }
+ dismiss()
}
-
- dismiss()
}
}