Show full paywall inline and disable add button when upgrade required

- Update UpgradeFeatureView to display complete paywall experience
  (promo content, subscription products, purchase flow) instead of
  simple teaser
- Disable add button in Documents view when upgrade screen is showing
- Disable add button in Contractors view when upgrade screen is showing
- Gray out disabled add buttons to indicate non-interactive state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-24 22:05:59 -06:00
parent 67e0057bfa
commit f3fbee1e27
3 changed files with 191 additions and 46 deletions

View File

@@ -26,6 +26,11 @@ struct ContractorsListView: View {
viewModel.contractors
}
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors")
}
var body: some View {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
@@ -130,7 +135,7 @@ struct ContractorsListView: View {
.foregroundColor(selectedSpecialty != nil ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button
// Add Button (disabled when showing upgrade screen)
Button(action: {
// Check LIVE contractor count before adding
let currentCount = viewModel.contractors.count
@@ -142,8 +147,9 @@ struct ContractorsListView: View {
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
}
.disabled(shouldShowUpgrade)
.accessibilityIdentifier(AccessibilityIdentifiers.Contractor.addButton)
}
}

View File

@@ -28,6 +28,11 @@ struct DocumentsWarrantiesView: View {
documentViewModel.documents.filter { $0.documentType != "warranty" }
}
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents")
}
var body: some View {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
@@ -154,7 +159,7 @@ struct DocumentsWarrantiesView: View {
.foregroundColor((selectedCategory != nil || selectedDocType != nil) ? Color.appPrimary : Color.appTextSecondary)
}
// Add Button
// Add Button (disabled when showing upgrade screen)
Button(action: {
// Check LIVE document count before adding
let currentCount = documentViewModel.documents.count
@@ -166,8 +171,9 @@ struct DocumentsWarrantiesView: View {
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
.foregroundColor(shouldShowUpgrade ? Color.appTextSecondary.opacity(0.5) : Color.appPrimary)
}
.disabled(shouldShowUpgrade)
}
}
}

View File

@@ -1,12 +1,18 @@
import SwiftUI
import ComposeApp
import StoreKit
struct UpgradeFeatureView: View {
let triggerKey: String
let icon: String
@State private var showUpgradePrompt = false
@State private var showFeatureComparison = false
@State private var isProcessing = false
@State private var selectedProduct: Product?
@State private var errorMessage: String?
@State private var showSuccessAlert = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKit = StoreKitManager.shared
// Look up trigger data from cache
private var triggerData: UpgradeTriggerData? {
@@ -27,58 +33,185 @@ struct UpgradeFeatureView: View {
}
var body: some View {
VStack(spacing: AppSpacing.xl) {
Spacer()
ScrollView {
VStack(spacing: AppSpacing.xl) {
// Icon
Image(systemName: "star.circle.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appAccent.gradient)
.padding(.top, AppSpacing.xl)
// Feature Icon
Image(systemName: icon)
.font(.system(size: 80))
.foregroundStyle(Color.appPrimary.gradient)
// Title
Text(title)
.font(.title2.weight(.bold))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Title
Text(title)
.font(.title.weight(.bold))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl)
// Message
Text(message)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Description
Text(message)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl)
// Pro Features Preview - Dynamic content or fallback
Group {
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
PromoContentView(content: promoContent)
.padding()
} else {
// Fallback to static features if no promo content
VStack(alignment: .leading, spacing: AppSpacing.md) {
FeatureRow(icon: "house.fill", text: "Unlimited properties")
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
FeatureRow(icon: "person.2.fill", text: "Contractor management")
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
}
.padding()
}
}
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.padding(.horizontal)
// Upgrade Message
Text("This feature is available with Pro")
.font(.subheadline.weight(.medium))
.foregroundColor(Color.appAccent)
.padding(.horizontal, AppSpacing.md)
.padding(.vertical, AppSpacing.sm)
.background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.md)
// Subscription Products
if storeKit.isLoading {
ProgressView()
.tint(Color.appPrimary)
.padding()
} else if !storeKit.products.isEmpty {
VStack(spacing: AppSpacing.md) {
ForEach(storeKit.products, id: \.id) { product in
SubscriptionProductButton(
product: product,
isSelected: selectedProduct?.id == product.id,
isProcessing: isProcessing,
onSelect: {
selectedProduct = product
handlePurchase(product)
}
)
}
}
.padding(.horizontal)
} else {
// Fallback upgrade button if products fail to load
Button(action: {
Task { await storeKit.loadProducts() }
}) {
HStack {
if isProcessing {
ProgressView()
.tint(Color.appTextOnPrimary)
} else {
Text("Retry Loading Products")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.foregroundColor(Color.appTextOnPrimary)
.padding()
.background(Color.appPrimary)
.cornerRadius(AppRadius.md)
}
.disabled(isProcessing)
.padding(.horizontal)
}
// Upgrade Button
Button(action: {
showUpgradePrompt = true
}) {
Text(buttonText)
.font(.headline)
.foregroundColor(Color.appTextOnPrimary)
.frame(maxWidth: .infinity)
// Error Message
if let error = errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(Color.appError)
Text(error)
.font(.subheadline)
.foregroundColor(Color.appError)
}
.padding()
.background(Color.appPrimary)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
}
.padding(.horizontal, AppSpacing.xl)
.padding(.top, AppSpacing.lg)
.padding(.horizontal)
}
Spacer()
// Compare Plans
Button(action: {
showFeatureComparison = true
}) {
Text("Compare Free vs Pro")
.font(.subheadline)
.foregroundColor(Color.appPrimary)
}
// Restore Purchases
Button(action: {
handleRestore()
}) {
Text("Restore Purchases")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
.padding(.bottom, AppSpacing.xxxl)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.appBackgroundPrimary)
.sheet(isPresented: $showUpgradePrompt) {
UpgradePromptView(triggerKey: triggerKey, isPresented: $showUpgradePrompt)
.sheet(isPresented: $showFeatureComparison) {
FeatureComparisonView(isPresented: $showFeatureComparison)
}
.alert("Subscription Active", isPresented: $showSuccessAlert) {
Button("Done") { }
} message: {
Text("You now have full access to all Pro features!")
}
.task {
// Refresh subscription cache to get latest upgrade triggers
subscriptionCache.refreshFromCache()
await storeKit.loadProducts()
}
}
private func handlePurchase(_ product: Product) {
isProcessing = true
errorMessage = nil
Task {
do {
let transaction = try await storeKit.purchase(product)
await MainActor.run {
isProcessing = false
if transaction != nil {
// Purchase successful
showSuccessAlert = true
}
}
} catch {
await MainActor.run {
isProcessing = false
errorMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
private func handleRestore() {
isProcessing = true
errorMessage = nil
Task {
await storeKit.restorePurchases()
await MainActor.run {
isProcessing = false
if !storeKit.purchasedProductIDs.isEmpty {
showSuccessAlert = true
} else {
errorMessage = "No purchases found to restore"
}
}
}
}
}