Commit dcf4135ec3

dcf4135ec376d357b8fafc0101a75b674e76329a

parent: 37b02e5ee3

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 17:39 UTC

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

Layout: unified · split

DomainDig/AppVersion.swift +1 −1
@@ -2,6 +2,6 @@ import Foundation
2 2
3enum AppVersion { 3enum AppVersion {
4 static var current: String { 4 static var current: String {
5 "3.0.0" 5 "3.1.0"
6 } 6 }
7} 7}
DomainDig/ContentView.swift +62 −52
@@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable {
25struct ContentView: View { 25struct ContentView: View {
26 @Environment(\.appDensity) private var appDensity 26 @Environment(\.appDensity) private var appDensity
27 @Bindable var viewModel: DomainViewModel 27 @Bindable var viewModel: DomainViewModel
28 @State private var purchaseService = PurchaseService.shared
28 @State private var navigationPath = NavigationPath() 29 @State private var navigationPath = NavigationPath()
29 @FocusState private var domainFieldFocused: Bool 30 @FocusState private var domainFieldFocused: Bool
30 @State private var customPortInput = "" 31 @State private var customPortInput = ""
31 @State private var customPortsExpanded = false 32 @State private var customPortsExpanded = false
32 @State private var trackingNoteDraft = "" 33 @State private var trackingNoteDraft = ""
33 @State private var editingTrackedDomain: TrackedDomain? 34 @State private var editingTrackedDomain: TrackedDomain?
34 @State private var showTrackLimitAlert = false
35 @State private var featureGateMessage: String?
36 @State private var inputMode: LookupInputMode = .single 35 @State private var inputMode: LookupInputMode = .single
37 @State private var collapsedSections: Set<ResultSection> = [.network] 36 @State private var collapsedSections: Set<ResultSection> = [.network]
38 @State private var showingCurrentDomainWorkflowSheet = false 37 @State private var showingCurrentDomainWorkflowSheet = false
39 @State private var showingBatchWorkflowSheet = false 38 @State private var showingBatchWorkflowSheet = false
40 39
41 var body: some View { 40 var body: some View {
41 let _ = purchaseService.currentTier
42
42 NavigationStack(path: $navigationPath) { 43 NavigationStack(path: $navigationPath) {
43 ScrollView(.vertical) { 44 ScrollView(.vertical) {
44 VStack(spacing: 0) { 45 VStack(spacing: 0) {
@@ -84,9 +85,7 @@ struct ContentView: View {
84 workflows: viewModel.currentDomainWorkflows, 85 workflows: viewModel.currentDomainWorkflows,
85 trackingLimitMessage: viewModel.trackingLimitMessage, 86 trackingLimitMessage: viewModel.trackingLimitMessage,
86 onTrack: { 87 onTrack: {
87 if !viewModel.trackCurrentDomain() { 88 _ = viewModel.trackCurrentDomain()
88 showTrackLimitAlert = true
89 }
90 }, 89 },
91 onTogglePinned: { 90 onTogglePinned: {
92 guard let trackedDomain = viewModel.currentTrackedDomain else { return } 91 guard let trackedDomain = viewModel.currentTrackedDomain else { return }
@@ -248,23 +247,6 @@ struct ContentView: View {
248 navigationPath = NavigationPath() 247 navigationPath = NavigationPath()
249 domainFieldFocused = false 248 domainFieldFocused = false
250 } 249 }
251 .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) {
252 Button("OK", role: .cancel) {}
253 } message: {
254 Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.")
255 }
256 .alert("Feature unavailable", isPresented: Binding(
257 get: { featureGateMessage != nil },
258 set: { newValue in
259 if !newValue {
260 featureGateMessage = nil
261 }
262 }
263 )) {
264 Button("OK", role: .cancel) {}
265 } message: {
266 Text(featureGateMessage ?? "")
267 }
268 .sheet(item: $editingTrackedDomain) { trackedDomain in 250 .sheet(item: $editingTrackedDomain) { trackedDomain in
269 NavigationStack { 251 NavigationStack {
270 Form { 252 Form {
@@ -346,6 +328,12 @@ struct ContentView: View {
346 .font(appDensity.font(.caption)) 328 .font(appDensity.font(.caption))
347 .foregroundStyle(.secondary) 329 .foregroundStyle(.secondary)
348 330
331 if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() {
332 Text(batchAllowanceSummary)
333 .font(appDensity.font(.caption2))
334 .foregroundStyle(.secondary)
335 }
336
349 TextField( 337 TextField(
350 "example.com\napple.com, openai.com", 338 "example.com\napple.com, openai.com",
351 text: $viewModel.bulkInput, 339 text: $viewModel.bulkInput,
@@ -391,25 +379,25 @@ struct ContentView: View {
391 Menu { 379 Menu {
392 if !viewModel.isCurrentDomainTracked { 380 if !viewModel.isCurrentDomainTracked {
393 Button("Track this domain") { 381 Button("Track this domain") {
394 if !viewModel.trackCurrentDomain() { 382 _ = viewModel.trackCurrentDomain()
395 showTrackLimitAlert = true
396 }
397 } 383 }
398 } 384 }
399 Button("Add to workflow") { 385 Button("Add to workflow") {
400 if FeatureAccessService.hasAccess(to: .workflows) { 386 showingCurrentDomainWorkflowSheet = true
401 showingCurrentDomainWorkflowSheet = true
402 } else {
403 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
404 }
405 } 387 }
406 Button("Copy report JSON") { 388 if FeatureAccessService.hasAccess(to: .advancedExports) {
407 guard let json = viewModel.exportJSONString() else { return } 389 Button("Copy report JSON") {
408 AppClipboard.copy(json) 390 guard let json = viewModel.exportJSONString() else { return }
409 AppHaptics.copy() 391 AppClipboard.copy(json)
392 AppHaptics.copy()
393 }
394 } else {
395 Button("Copy report JSON") {
396 viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports)
397 }
410 } 398 }
411 Button("Export report") { 399 Button("Export report") {
412 shareSingleResults(format: .json) 400 shareSingleResults(format: .text)
413 } 401 }
414 } label: { 402 } label: {
415 Image(systemName: "bolt.circle") 403 Image(systemName: "bolt.circle")
@@ -463,11 +451,7 @@ struct ContentView: View {
463 if !viewModel.currentBatchResultEntries.isEmpty { 451 if !viewModel.currentBatchResultEntries.isEmpty {
464 Menu { 452 Menu {
465 Button("Add to Workflow") { 453 Button("Add to Workflow") {
466 if FeatureAccessService.hasAccess(to: .workflows) { 454 showingBatchWorkflowSheet = true
467 showingBatchWorkflowSheet = true
468 } else {
469 featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows)
470 }
471 } 455 }
472 Divider() 456 Divider()
473 Button("Export Batch TXT") { 457 Button("Export Batch TXT") {
@@ -2165,6 +2149,7 @@ private extension String {
2165struct SettingsView: View { 2149struct SettingsView: View {
2166 @Environment(\.appDensity) private var appDensity 2150 @Environment(\.appDensity) private var appDensity
2167 @Bindable var viewModel: DomainViewModel 2151 @Bindable var viewModel: DomainViewModel
2152 @State private var purchaseService = PurchaseService.shared
2168 @AppStorage(DNSResolverOption.userDefaultsKey) 2153 @AppStorage(DNSResolverOption.userDefaultsKey)
2169 private var storedResolverURL = DNSResolverOption.defaultURLString 2154 private var storedResolverURL = DNSResolverOption.defaultURLString
2170 @AppStorage(AppDensity.userDefaultsKey) 2155 @AppStorage(AppDensity.userDefaultsKey)
@@ -2185,6 +2170,8 @@ struct SettingsView: View {
2185 } 2170 }
2186 2171
2187 var body: some View { 2172 var body: some View {
2173 let _ = purchaseService.currentTier
2174
2188 Form { 2175 Form {
2189 Section("Display") { 2176 Section("Display") {
2190 Picker("Density", selection: $storedDensity) { 2177 Picker("Density", selection: $storedDensity) {
@@ -2233,28 +2220,51 @@ struct SettingsView: View {
2233 } 2220 }
2234 } 2221 }
2235 2222
2236 Section("Features") { 2223 Section("Pro") {
2237 LabeledContent("Tier", value: FeatureAccessService.currentTier.title) 2224 LabeledContent("Status", value: purchaseService.currentTier.title)
2238 2225
2239 if FeatureAccessService.enabledFeatureLabels().isEmpty { 2226 if purchaseService.currentTier == .free {
2240 Text("No features enabled.") 2227 Button("Upgrade to Pro") {
2241 .font(appDensity.font(.caption, design: .default)) 2228 viewModel.isPaywallPresented = true
2242 .foregroundStyle(.secondary) 2229 }
2243 } else { 2230 } else {
2244 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in 2231 Button("Manage Subscription") {
2245 Text(label) 2232 Task {
2233 await purchaseService.manageSubscription()
2234 }
2246 } 2235 }
2247 } 2236 }
2248 2237
2249 Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.") 2238 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
2250 .font(appDensity.font(.caption, design: .default)) 2239 Task {
2251 .foregroundStyle(.secondary) 2240 await purchaseService.restorePurchases()
2241 }
2242 }
2243 .disabled(purchaseService.isRestoring || purchaseService.isPurchasing)
2244
2245 if let statusMessage = purchaseService.statusMessage {
2246 Text(statusMessage)
2247 .font(appDensity.font(.caption, design: .default))
2248 .foregroundStyle(.secondary)
2249 }
2250
2251 if let errorMessage = purchaseService.errorMessage {
2252 Text(errorMessage)
2253 .font(appDensity.font(.caption, design: .default))
2254 .foregroundStyle(.red)
2255 }
2256 }
2257
2258 Section("Features") {
2259 ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in
2260 Text(label)
2261 }
2252 } 2262 }
2253 2263
2254 Section("About") { 2264 Section("About") {
2255 LabeledContent("Version", value: appVersion) 2265 LabeledContent("Version", value: appVersion)
2256 LabeledContent("Storage", value: "Local-only") 2266 LabeledContent("Storage", value: "Local-only")
2257 LabeledContent("Report Schema", value: "3.0.0") 2267 LabeledContent("Report Schema", value: "3.1.0")
2258 } 2268 }
2259 } 2269 }
2260 .navigationTitle("Settings") 2270 .navigationTitle("Settings")
DomainDig/DomainDigApp.swift +5
@@ -11,6 +11,7 @@ import SwiftUI
11struct DomainDigApp: App { 11struct DomainDigApp: App {
12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue 12 @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue
13 @State private var viewModel = DomainViewModel() 13 @State private var viewModel = DomainViewModel()
14 @State private var purchaseService = PurchaseService.shared
14 15
15 init() { 16 init() {
16 LocalNotificationService.shared.configureForegroundPresentation() 17 LocalNotificationService.shared.configureForegroundPresentation()
@@ -20,6 +21,10 @@ struct DomainDigApp: App {
20 WindowGroup { 21 WindowGroup {
21 RootTabView(viewModel: viewModel) 22 RootTabView(viewModel: viewModel)
22 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) 23 .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact)
24 .task {
25 let _ = purchaseService.currentTier
26 await purchaseService.refreshEntitlements()
27 }
23 } 28 }
24 } 29 }
25} 30}
DomainDig/DomainViewModel.swift +19 −4
@@ -226,6 +226,8 @@ final class DomainViewModel {
226 var watchlistSearchText = "" 226 var watchlistSearchText = ""
227 var watchlistFilter: WatchlistFilterOption = .all 227 var watchlistFilter: WatchlistFilterOption = .all
228 var watchlistSortOption: WatchlistSortOption = .pinned 228 var watchlistSortOption: WatchlistSortOption = .pinned
229 var upgradePrompt: UpgradePromptContext?
230 var isPaywallPresented = false
229 231
230 var trimmedDomain: String { 232 var trimmedDomain: String {
231 domain 233 domain
@@ -578,6 +580,7 @@ final class DomainViewModel {
578 } 580 }
579 581
580 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { 582 guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else {
583 upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count)
581 return false 584 return false
582 } 585 }
583 586
@@ -737,12 +740,18 @@ final class DomainViewModel {
737 func runBulkLookup() { 740 func runBulkLookup() {
738 let domains = parsedDomains(from: bulkInput) 741 let domains = parsedDomains(from: bulkInput)
739 guard !domains.isEmpty else { return } 742 guard !domains.isEmpty else { return }
740 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } 743 guard FeatureAccessService.canRunBatch(domainCount: domains.count) else {
744 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count)
745 return
746 }
741 startBatchLookup(domains: domains, source: .manual) 747 startBatchLookup(domains: domains, source: .manual)
742 } 748 }
743 749
744 func refreshAllTrackedDomains() { 750 func refreshAllTrackedDomains() {
745 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } 751 guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else {
752 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count)
753 return
754 }
746 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) 755 startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh)
747 } 756 }
748 757
@@ -791,7 +800,10 @@ final class DomainViewModel {
791 let normalizedDomains = normalizedDomains(domains) 800 let normalizedDomains = normalizedDomains(domains)
792 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) 801 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
793 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } 802 guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil }
794 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil } 803 guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else {
804 upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count)
805 return nil
806 }
795 807
796 let workflow = DomainWorkflow( 808 let workflow = DomainWorkflow(
797 name: trimmedName, 809 name: trimmedName,
@@ -855,7 +867,10 @@ final class DomainViewModel {
855 867
856 func runWorkflow(_ workflow: DomainWorkflow) { 868 func runWorkflow(_ workflow: DomainWorkflow) {
857 guard !workflow.domains.isEmpty else { return } 869 guard !workflow.domains.isEmpty else { return }
858 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } 870 guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else {
871 upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count)
872 return
873 }
859 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) 874 startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow)
860 } 875 }
861 876
DomainDig/FeatureAccessService.swift +75 −21
@@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable {
64 let batchSizeLimit: Int? 64 let batchSizeLimit: Int?
65} 65}
66 66
67struct UpgradePromptContext: Identifiable, Equatable {
68 let id = UUID()
69 let title: String
70 let message: String
71 let capability: FeatureCapability
72}
73
67enum FeatureAccessService { 74enum FeatureAccessService {
68 static let currentTier: FeatureTier = .free 75 private static let effectivelyUnlimitedTrackedDomains = 5_000
76
77 static var currentTier: FeatureTier {
78 PurchaseService.cachedTier
79 }
69 80
70 static var entitlements: FeatureEntitlements { 81 static var entitlements: FeatureEntitlements {
71 switch currentTier { 82 switch currentTier {
72 case .free: 83 case .free:
73 return FeatureEntitlements( 84 return FeatureEntitlements(
74 tier: .free, 85 tier: .free,
75 capabilities: [.singleLookup, .basicHistory, .limitedTracking], 86 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations],
76 trackedDomainLimit: 3, 87 trackedDomainLimit: 5,
77 workflowLimit: 0, 88 workflowLimit: 1,
78 batchSizeLimit: 0 89 batchSizeLimit: 10
79 ) 90 )
80 case .pro: 91 case .pro:
81 return FeatureEntitlements( 92 return FeatureEntitlements(
82 tier: .pro, 93 tier: .pro,
83 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], 94 capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports],
84 trackedDomainLimit: 250, 95 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
85 workflowLimit: 50, 96 workflowLimit: nil,
86 batchSizeLimit: 100 97 batchSizeLimit: nil
87 ) 98 )
88 case .dataPlus: 99 case .dataPlus:
89 return FeatureEntitlements( 100 return FeatureEntitlements(
90 tier: .dataPlus, 101 tier: .dataPlus,
91 capabilities: Set(FeatureCapability.allCases), 102 capabilities: Set(FeatureCapability.allCases),
92 trackedDomainLimit: 1_000, 103 trackedDomainLimit: effectivelyUnlimitedTrackedDomains,
93 workflowLimit: 200, 104 workflowLimit: nil,
94 batchSizeLimit: 250 105 batchSizeLimit: nil
95 ) 106 )
96 } 107 }
97 } 108 }
@@ -106,9 +117,10 @@ enum FeatureAccessService {
106 117
107 static func trackedDomainLimitMessage(currentCount: Int) -> String? { 118 static func trackedDomainLimitMessage(currentCount: Int) -> String? {
108 guard currentTier == .free else { return nil } 119 guard currentTier == .free else { return nil }
109 return currentCount >= entitlements.trackedDomainLimit 120 if currentCount >= entitlements.trackedDomainLimit {
110 ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." 121 return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro."
111 : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." 122 }
123 return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains."
112 } 124 }
113 125
114 static func canCreateWorkflow(currentCount: Int) -> Bool { 126 static func canCreateWorkflow(currentCount: Int) -> Bool {
@@ -137,19 +149,61 @@ enum FeatureAccessService {
137 } 149 }
138 150
139 static func workflowLimitMessage(currentCount: Int) -> String? { 151 static func workflowLimitMessage(currentCount: Int) -> String? {
140 guard hasAccess(to: .workflows) else {
141 return upgradeMessage(for: .workflows)
142 }
143 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } 152 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
144 return "Workflow limit reached." 153 return "Free includes up to \(limit) workflow."
145 } 154 }
146 155
147 static func batchLimitMessage(domainCount: Int) -> String? { 156 static func batchLimitMessage(domainCount: Int) -> String? {
148 guard hasAccess(to: .batchOperations) else { 157 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
149 return upgradeMessage(for: .batchOperations) 158 return "Free runs batches up to \(limit) domains."
159 }
160
161 static func workflowAllowanceSummary(currentCount: Int) -> String? {
162 guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil }
163 if currentCount >= limit {
164 return "Free includes up to \(limit) workflow. Available in Pro."
150 } 165 }
166 return "Free includes up to \(limit) workflow."
167 }
168
169 static func batchAllowanceSummary() -> String? {
170 guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil }
171 return "Free runs batches up to \(limit) domains."
172 }
173
174 static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? {
175 guard currentCount >= entitlements.trackedDomainLimit else { return nil }
176 return UpgradePromptContext(
177 title: "Available in Pro",
178 message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.",
179 capability: .limitedTracking
180 )
181 }
182
183 static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? {
184 guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil }
185 return UpgradePromptContext(
186 title: "Available in Pro",
187 message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.",
188 capability: .workflows
189 )
190 }
191
192 static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? {
151 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } 193 guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil }
152 return "Batch limit is \(limit) domains on \(currentTier.title)." 194 return UpgradePromptContext(
195 title: "Available in Pro",
196 message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.",
197 capability: .batchOperations
198 )
199 }
200
201 static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext {
202 UpgradePromptContext(
203 title: "Available in Pro",
204 message: upgradeMessage(for: capability),
205 capability: capability
206 )
153 } 207 }
154 208
155 static func enabledFeatureLabels() -> [String] { 209 static func enabledFeatureLabels() -> [String] {
DomainDig/PaywallView.swift added +123
@@ -0,0 +1,123 @@
1import StoreKit
2import SwiftUI
3
4struct PaywallView: View {
5 @Environment(\.dismiss) private var dismiss
6 @Environment(\.appDensity) private var appDensity
7 @State private var purchaseService = PurchaseService.shared
8
9 var body: some View {
10 NavigationStack {
11 List {
12 Section {
13 Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.")
14 .font(appDensity.font(.body, design: .default))
15 .foregroundStyle(.secondary)
16 .fixedSize(horizontal: false, vertical: true)
17 }
18
19 Section("What Pro Unlocks") {
20 featureRow("Unlimited tracked domains")
21 featureRow("Workflows")
22 featureRow("Larger batch sizes")
23 featureRow("Advanced exports")
24 }
25
26 Section("Subscription") {
27 if purchaseService.isLoadingProducts {
28 ProgressView("Loading pricing…")
29 } else if purchaseService.products.isEmpty {
30 Text("Pricing is unavailable right now.")
31 .font(appDensity.font(.caption, design: .default))
32 .foregroundStyle(.secondary)
33
34 Button("Retry") {
35 Task {
36 await purchaseService.refreshProducts()
37 }
38 }
39 } else {
40 ForEach(purchaseService.products, id: \.id) { product in
41 Button {
42 Task {
43 await purchaseService.purchase(product)
44 }
45 } label: {
46 VStack(alignment: .leading, spacing: 6) {
47 Text(subscriptionTitle(for: product))
48 .font(appDensity.font(.headline, design: .default, weight: .semibold))
49 .foregroundStyle(.primary)
50 Text(product.displayPrice)
51 .font(appDensity.font(.callout, design: .default))
52 .foregroundStyle(.secondary)
53 }
54 .frame(maxWidth: .infinity, alignment: .leading)
55 }
56 .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
57 }
58 }
59
60 if let statusMessage = purchaseService.statusMessage {
61 Text(statusMessage)
62 .font(appDensity.font(.caption, design: .default))
63 .foregroundStyle(.secondary)
64 }
65
66 if let errorMessage = purchaseService.errorMessage {
67 Text(errorMessage)
68 .font(appDensity.font(.caption, design: .default))
69 .foregroundStyle(.red)
70 }
71 }
72
73 Section("Account") {
74 Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") {
75 Task {
76 await purchaseService.restorePurchases()
77 }
78 }
79 .disabled(purchaseService.isPurchasing || purchaseService.isRestoring)
80
81 if purchaseService.hasProAccess {
82 Button("Manage Subscription") {
83 Task {
84 await purchaseService.manageSubscription()
85 }
86 }
87 }
88 }
89 }
90 .navigationTitle("DomainDig Pro")
91 .toolbar {
92 ToolbarItem(placement: .topBarTrailing) {
93 Button("Done") {
94 dismiss()
95 }
96 }
97 }
98 }
99 .task {
100 await purchaseService.refreshProducts()
101 await purchaseService.refreshEntitlements()
102 }
103 .onDisappear {
104 purchaseService.clearMessages()
105 }
106 }
107
108 private func featureRow(_ title: String) -> some View {
109 Text(title)
110 .font(appDensity.font(.body, design: .default))
111 }
112
113 private func subscriptionTitle(for product: Product) -> String {
114 switch product.id {
115 case PurchaseService.monthlyProductID:
116 return "Pro Monthly"
117 case PurchaseService.yearlyProductID:
118 return "Pro Yearly"
119 default:
120 return product.displayName
121 }
122 }
123}
DomainDig/PurchaseService.swift added +245
@@ -0,0 +1,245 @@
1import Foundation
2import StoreKit
3
4#if canImport(UIKit)
5import UIKit
6#endif
7
8@MainActor
9@Observable
10final class PurchaseService {
11 struct CachedEntitlement: Codable {
12 let tier: FeatureTier
13 let activeProductID: String?
14 let updatedAt: Date
15 }
16
17 static let shared = PurchaseService()
18 static let monthlyProductID = "domaindig.pro.monthly"
19 static let yearlyProductID = "domaindig.pro.yearly"
20 static let productIDs = [monthlyProductID, yearlyProductID]
21
22 private static let entitlementCacheKey = "purchase.cachedEntitlement"
23
24 static var cachedEntitlement: CachedEntitlement? {
25 guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil }
26 return try? JSONDecoder().decode(CachedEntitlement.self, from: data)
27 }
28
29 static var cachedTier: FeatureTier {
30 cachedEntitlement?.tier ?? .free
31 }
32
33 var products: [Product] = []
34 var currentTier: FeatureTier
35 var activeProductID: String?
36 var isLoadingProducts = false
37 var isPurchasing = false
38 var isRestoring = false
39 var statusMessage: String?
40 var errorMessage: String?
41
42 private var updatesTask: Task<Void, Never>?
43
44 private init() {
45 currentTier = Self.cachedTier
46 activeProductID = Self.cachedEntitlement?.activeProductID
47 updatesTask = observeTransactionUpdates()
48 Task {
49 await refreshProducts()
50 await refreshEntitlements()
51 }
52 }
53
54 var hasProAccess: Bool {
55 currentTier == .pro
56 }
57
58 func refreshProducts() async {
59 isLoadingProducts = true
60 errorMessage = nil
61
62 do {
63 let fetchedProducts = try await Product.products(for: Self.productIDs)
64 products = fetchedProducts.sorted { lhs, rhs in
65 productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id)
66 }
67 } catch {
68 products = []
69 errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.")
70 }
71
72 isLoadingProducts = false
73 }
74
75 func refreshEntitlements() async {
76 var activeTransactions: [Transaction] = []
77
78 for await result in Transaction.currentEntitlements {
79 guard case .verified(let transaction) = result else {
80 continue
81 }
82 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
83 continue
84 }
85 activeTransactions.append(transaction)
86 }
87
88 let activeProductID = activeTransactions
89 .sorted { $0.purchaseDate > $1.purchaseDate }
90 .first?
91 .productID
92
93 self.activeProductID = activeProductID
94 currentTier = activeProductID == nil ? .free : .pro
95 persistCurrentEntitlement()
96 }
97
98 func purchase(_ product: Product) async {
99 isPurchasing = true
100 statusMessage = nil
101 errorMessage = nil
102
103 do {
104 let result = try await product.purchase()
105
106 switch result {
107 case .success(let verification):
108 let transaction = try verifiedTransaction(from: verification)
109 apply(transaction: transaction)
110 await transaction.finish()
111 await refreshEntitlements()
112 statusMessage = "Pro is active."
113 case .userCancelled:
114 break
115 case .pending:
116 statusMessage = "Purchase is pending approval."
117 @unknown default:
118 errorMessage = "The purchase could not be completed."
119 }
120 } catch {
121 errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.")
122 }
123
124 isPurchasing = false
125 }
126
127 func restorePurchases() async {
128 isRestoring = true
129 statusMessage = nil
130 errorMessage = nil
131
132 do {
133 try await AppStore.sync()
134 await refreshEntitlements()
135 statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found."
136 } catch {
137 errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.")
138 }
139
140 isRestoring = false
141 }
142
143 func manageSubscription() async {
144 errorMessage = nil
145
146 #if canImport(UIKit)
147 if ProcessInfo.processInfo.isiOSAppOnMac {
148 errorMessage = "Manage Subscription is not available on this device."
149 return
150 }
151
152 guard let scene = UIApplication.shared.connectedScenes
153 .compactMap({ $0 as? UIWindowScene })
154 .first(where: { $0.activationState == .foregroundActive }) else {
155 errorMessage = "Manage Subscription is not available right now."
156 return
157 }
158
159 do {
160 try await AppStore.showManageSubscriptions(in: scene)
161 } catch {
162 errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.")
163 }
164 #else
165 errorMessage = "Manage Subscription is not available on this platform."
166 #endif
167 }
168
169 func clearMessages() {
170 statusMessage = nil
171 errorMessage = nil
172 }
173
174 private func apply(transaction: Transaction) {
175 guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else {
176 return
177 }
178
179 activeProductID = transaction.productID
180 currentTier = .pro
181 persistCurrentEntitlement()
182 }
183
184 private func observeTransactionUpdates() -> Task<Void, Never> {
185 Task.detached(priority: .background) { [weak self] in
186 for await result in Transaction.updates {
187 guard let self else { return }
188 await self.handleTransactionUpdate(result)
189 }
190 }
191 }
192
193 private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
194 guard case .verified(let transaction) = result else { return }
195 apply(transaction: transaction)
196 await transaction.finish()
197 await refreshEntitlements()
198 }
199
200 private func persistCurrentEntitlement() {
201 let cachedEntitlement = CachedEntitlement(
202 tier: currentTier,
203 activeProductID: activeProductID,
204 updatedAt: Date()
205 )
206
207 if let data = try? JSONEncoder().encode(cachedEntitlement) {
208 UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey)
209 }
210 }
211
212 private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction {
213 switch result {
214 case .verified(let transaction):
215 return transaction
216 case .unverified:
217 throw StoreKitError.notEntitled
218 }
219 }
220
221 private func productSortIndex(for productID: String) -> Int {
222 switch productID {
223 case Self.monthlyProductID:
224 return 0
225 case Self.yearlyProductID:
226 return 1
227 default:
228 return Int.max
229 }
230 }
231
232 private func storeMessage(for error: Error, fallback: String) -> String {
233 if let storeKitError = error as? StoreKitError {
234 switch storeKitError {
235 case .networkError:
236 return "The App Store is offline right now."
237 default:
238 return fallback
239 }
240 }
241
242 let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
243 return message.isEmpty ? fallback : message
244 }
245}
DomainDig/RootTabView.swift +25
@@ -2,8 +2,11 @@ import SwiftUI
2 2
3struct RootTabView: View { 3struct RootTabView: View {
4 @Bindable var viewModel: DomainViewModel 4 @Bindable var viewModel: DomainViewModel
5 @State private var purchaseService = PurchaseService.shared
5 6
6 var body: some View { 7 var body: some View {
8 let _ = purchaseService.currentTier
9
7 TabView { 10 TabView {
8 ContentView(viewModel: viewModel) 11 ContentView(viewModel: viewModel)
9 .tabItem { 12 .tabItem {
@@ -38,5 +41,27 @@ struct RootTabView: View {
38 Label("Settings", systemImage: "gearshape") 41 Label("Settings", systemImage: "gearshape")
39 } 42 }
40 } 43 }
44 .sheet(isPresented: Binding(
45 get: { viewModel.isPaywallPresented },
46 set: { viewModel.isPaywallPresented = $0 }
47 )) {
48 PaywallView()
49 }
50 .alert(item: Binding(
51 get: { viewModel.upgradePrompt },
52 set: { viewModel.upgradePrompt = $0 }
53 )) { prompt in
54 Alert(
55 title: Text(prompt.title),
56 message: Text(prompt.message),
57 primaryButton: .default(Text("Open Paywall")) {
58 viewModel.upgradePrompt = nil
59 viewModel.isPaywallPresented = true
60 },
61 secondaryButton: .cancel(Text("Continue")) {
62 viewModel.upgradePrompt = nil
63 }
64 )
65 }
41 } 66 }
42} 67}
DomainDig/WatchlistView.swift +3
@@ -4,6 +4,7 @@ struct WatchlistView: View {
4 @Environment(\.appDensity) private var appDensity 4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel 5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss 6 @Environment(\.dismiss) private var dismiss
7 @State private var purchaseService = PurchaseService.shared
7 @State private var showWorkflowAddSheet = false 8 @State private var showWorkflowAddSheet = false
8 9
9 private var pinnedDomains: [TrackedDomain] { 10 private var pinnedDomains: [TrackedDomain] {
@@ -15,6 +16,8 @@ struct WatchlistView: View {
15 } 16 }
16 17
17 var body: some View { 18 var body: some View {
19 let _ = purchaseService.currentTier
20
18 List { 21 List {
19 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { 22 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
20 Section("Refresh Progress") { 23 Section("Refresh Progress") {
DomainDig/WorkflowsView.swift +28 −25
@@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
10struct WorkflowsView: View { 10struct WorkflowsView: View {
11 @Environment(\.appDensity) private var appDensity 11 @Environment(\.appDensity) private var appDensity
12 @Bindable var viewModel: DomainViewModel 12 @Bindable var viewModel: DomainViewModel
13 @State private var purchaseService = PurchaseService.shared
13 14
14 @State private var showingCreateWorkflow = false 15 @State private var showingCreateWorkflow = false
15 16
16 var body: some View { 17 var body: some View {
18 let _ = purchaseService.currentTier
19
17 List { 20 List {
18 if FeatureAccessService.hasAccess(to: .workflows) { 21 if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
19 workflowContent
20 } else {
21 Section { 22 Section {
22 EmptyStateCardView( 23 Text(limitMessage)
23 title: "Workflows", 24 .font(appDensity.font(.caption))
24 message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.", 25 .foregroundStyle(.secondary)
25 suggestion: FeatureAccessService.upgradeMessage(for: .workflows),
26 systemImage: "square.stack.3d.down.right"
27 )
28 } 26 }
29 .listRowBackground(Color(.systemGray6).opacity(0.5)) 27 .listRowBackground(Color(.systemGray6).opacity(0.5))
30 } 28 }
29
30 workflowContent
31 } 31 }
32 .scrollContentBackground(.hidden) 32 .scrollContentBackground(.hidden)
33 .background(Color.black) 33 .background(Color.black)
@@ -36,17 +36,15 @@ struct WorkflowsView: View {
36 } 36 }
37 .navigationTitle("Workflows") 37 .navigationTitle("Workflows")
38 .toolbar { 38 .toolbar {
39 if FeatureAccessService.hasAccess(to: .workflows) { 39 ToolbarItemGroup(placement: .topBarTrailing) {
40 ToolbarItemGroup(placement: .topBarTrailing) { 40 Button {
41 Button { 41 showingCreateWorkflow = true
42 showingCreateWorkflow = true 42 } label: {
43 } label: { 43 Image(systemName: "plus.circle")
44 Image(systemName: "plus.circle") 44 }
45 }
46 45
47 if !viewModel.workflows.isEmpty { 46 if !viewModel.workflows.isEmpty {
48 EditButton() 47 EditButton()
49 }
50 } 48 }
51 } 49 }
52 } 50 }
@@ -429,10 +427,13 @@ struct WorkflowComposerView: View {
429 private func saveWorkflow() { 427 private func saveWorkflow() {
430 if let workflow { 428 if let workflow {
431 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes) 429 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
432 } else { 430 dismiss()
433 _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) 431 return
432 }
433
434 if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
435 dismiss()
434 } 436 }
435 dismiss()
436 } 437 }
437} 438}
438 439
@@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View {
678 case .existing: 679 case .existing:
679 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return } 680 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
680 viewModel.addDomains(selectedDomainList, to: workflow) 681 viewModel.addDomains(selectedDomainList, to: workflow)
682 dismiss()
681 case .new: 683 case .new:
682 _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) 684 guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
685 return
686 }
687 dismiss()
683 } 688 }
684
685 dismiss()
686 } 689 }
687} 690}