197bddcd7e
CRÍTICO — enlaces del App Store rotos (adquisición viral = 0): - GoalShareService.appStoreURL apuntaba a id6744983373 (404): el QR de TODAS las imágenes compartidas llevaba a una página muerta. Corregido a id6757678318. - SettingsView enlazaba id6741412965 (una novela romántica). Ahora usa la constante. Monetización: - IAPService multi-producto: sub anual (com.portfoliojournal.premium.annual, con intro offer/trial) + lifetime como ancla. isPremium acepta cualquiera de los dos. Paywall con selector de plan (anual por defecto si existe; degrada a solo lifetime mientras el producto no esté creado en App Store Connect). - paywallBenefits: añadidos Multiple Accounts y Family Sharing (diferenciadores). - Teaser de historia bloqueada en Charts: "N meses más con Premium" en vez de truncar en silencio (FreemiumValidator.hiddenHistoryMonths + banner). - Trigger de paywall de backups ahora instrumentado (logPaywallShown "backups"). Retención / adquisición: - Rating velocity: prompt tras 2 check-ins y 30 días (antes 3 + 90 — en una app mensual eso era >3 meses sin poder pedir la primera review con ~0 ratings). - Sample data ON por defecto en onboarding (skip ya no aterriza en dashboard vacío). - Notificación de protección de streak el día 25 si el check-in del mes está pendiente. - App Intents + AppShortcutsProvider: "Update my portfolio" / "Check my portfolio" vía Siri/Shortcuts/Spotlight. - Instrumentación: onboarding_step/onboarding_skipped y content_shared (viral loop). - ScreenshotMode (--screenshots) para capturas de marketing automatizadas. ASO: - 6 locales nuevos en metadata (en-GB, en-CA, en-AU, es-MX, fr-CA, pt-PT) reusando traducciones — 6 campos de keywords extra; pt-PT adaptado (reforma). - Categoría secundaria: PRODUCTIVITY. - 17 strings nuevas localizadas en los 7 idiomas. Pendiente manual: crear la sub anual en App Store Connect (grupo de suscripción + intro offer 7 días) con el id exacto com.portfoliojournal.premium.annual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
105 lines
4.0 KiB
Swift
105 lines
4.0 KiB
Swift
import StoreKit
|
|
import UIKit
|
|
|
|
final class ReviewPromptService {
|
|
static let shared = ReviewPromptService()
|
|
|
|
private let lastPromptKey = "reviewPromptLastDate"
|
|
private let checkInCountKey = "reviewPromptCheckinCount"
|
|
private let hasCompletedStoreReviewKey = "reviewPromptHasCompletedStoreReview"
|
|
private let promptedAchievementKeysKey = "reviewPromptedAchievementKeys"
|
|
// Rating velocity: with a monthly-cadence app, 3 check-ins + 90 days meant 3+ months
|
|
// before the first review could even be requested. 2 check-ins + 30 days keeps the
|
|
// prompt tied to a good moment (a completed check-in) while building ratings sooner.
|
|
private let minCheckInsBetweenPrompts = 2
|
|
private let minDaysBetweenPrompts = 30
|
|
private let userDefaults: UserDefaults
|
|
private let dateProvider: () -> Date
|
|
private let reviewRequestHandler: () -> Void
|
|
|
|
private init() {
|
|
userDefaults = .standard
|
|
dateProvider = Date.init
|
|
reviewRequestHandler = ReviewPromptService.defaultReviewRequestHandler
|
|
}
|
|
|
|
init(
|
|
userDefaults: UserDefaults,
|
|
dateProvider: @escaping () -> Date,
|
|
reviewRequestHandler: @escaping () -> Void
|
|
) {
|
|
self.userDefaults = userDefaults
|
|
self.dateProvider = dateProvider
|
|
self.reviewRequestHandler = reviewRequestHandler
|
|
}
|
|
|
|
func recordMonthlyCheckInCompleted() {
|
|
let currentCount = userDefaults.integer(forKey: checkInCountKey)
|
|
userDefaults.set(currentCount + 1, forKey: checkInCountKey)
|
|
requestReviewIfEligible()
|
|
}
|
|
|
|
func shouldAskForAchievementSatisfaction(newlyUnlockedAchievementKeys: Set<String>) -> Bool {
|
|
guard !newlyUnlockedAchievementKeys.isEmpty else { return false }
|
|
guard !hasCompletedStoreReview else { return false }
|
|
|
|
let promptedKeys = Set(userDefaults.stringArray(forKey: promptedAchievementKeysKey) ?? [])
|
|
let unpromptedKeys = newlyUnlockedAchievementKeys.subtracting(promptedKeys)
|
|
guard !unpromptedKeys.isEmpty else { return false }
|
|
|
|
let mergedKeys = promptedKeys.union(unpromptedKeys).sorted()
|
|
userDefaults.set(mergedKeys, forKey: promptedAchievementKeysKey)
|
|
return true
|
|
}
|
|
|
|
var hasCompletedStoreReview: Bool {
|
|
userDefaults.bool(forKey: hasCompletedStoreReviewKey)
|
|
}
|
|
|
|
func markStoreReviewCompleted() {
|
|
userDefaults.set(true, forKey: hasCompletedStoreReviewKey)
|
|
}
|
|
|
|
static func appStoreWriteReviewURL() -> URL {
|
|
guard var components = URLComponents(url: GoalShareService.appStoreURL, resolvingAgainstBaseURL: false) else {
|
|
return GoalShareService.appStoreURL
|
|
}
|
|
var queryItems = components.queryItems ?? []
|
|
queryItems.removeAll { $0.name == "action" }
|
|
queryItems.append(URLQueryItem(name: "action", value: "write-review"))
|
|
components.queryItems = queryItems
|
|
return components.url ?? GoalShareService.appStoreURL
|
|
}
|
|
|
|
private func requestReviewIfEligible() {
|
|
guard !hasCompletedStoreReview else { return }
|
|
let now = dateProvider()
|
|
if let lastPrompt = userDefaults.object(forKey: lastPromptKey) as? Date {
|
|
let daysSince = now.timeIntervalSince(lastPrompt) / 86_400
|
|
if daysSince < Double(minDaysBetweenPrompts) {
|
|
return
|
|
}
|
|
}
|
|
|
|
let count = userDefaults.integer(forKey: checkInCountKey)
|
|
guard count >= minCheckInsBetweenPrompts else { return }
|
|
|
|
requestReview()
|
|
}
|
|
|
|
private func requestReview() {
|
|
reviewRequestHandler()
|
|
userDefaults.set(dateProvider(), forKey: lastPromptKey)
|
|
userDefaults.set(0, forKey: checkInCountKey)
|
|
}
|
|
|
|
private static func defaultReviewRequestHandler() {
|
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
|
if #available(iOS 18.0, *) {
|
|
AppStore.requestReview(in: scene)
|
|
} else {
|
|
SKStoreReviewController.requestReview(in: scene)
|
|
}
|
|
}
|
|
}
|