From bebb566caf646f0d592a68c992abc427cadffc99 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:59:02 -0800 Subject: [PATCH] =?UTF-8?q?feat(growth):=20add=20referral=20program=20?= =?UTF-8?q?=E2=80=94=20unique=20codes,=20invite=20tracking,=20Pro=20reward?= =?UTF-8?q?=20months?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Shared/Growth/ReferralManager.swift | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 ios/ChronoMind/Shared/Growth/ReferralManager.swift diff --git a/ios/ChronoMind/Shared/Growth/ReferralManager.swift b/ios/ChronoMind/Shared/Growth/ReferralManager.swift new file mode 100644 index 0000000..21fd74d --- /dev/null +++ b/ios/ChronoMind/Shared/Growth/ReferralManager.swift @@ -0,0 +1,192 @@ +// ── Referral Manager ────────────────────────────────────────── +// "Invite a friend → both get 1 month Pro free" +// Unique referral code per user, track referral chain + +import Foundation +import Combine + +@MainActor +final class ReferralManager: ObservableObject { + static let shared = ReferralManager() + + @Published var myReferralCode: String + @Published var referralCount: Int = 0 + @Published var referredBy: String? + @Published var proRewardMonths: Int = 0 + @Published var referralHistory: [ReferralRecord] = [] + + private let codeKey = "chronomind-referral-code" + private let countKey = "chronomind-referral-count" + private let referredByKey = "chronomind-referred-by" + private let rewardKey = "chronomind-pro-reward-months" + private let historyKey = "chronomind-referral-history" + + private init() { + // Generate or load existing referral code + if let code = UserDefaults.standard.string(forKey: codeKey) { + myReferralCode = code + } else { + let code = Self.generateCode() + UserDefaults.standard.set(code, forKey: codeKey) + myReferralCode = code + } + referralCount = UserDefaults.standard.integer(forKey: countKey) + referredBy = UserDefaults.standard.string(forKey: referredByKey) + proRewardMonths = UserDefaults.standard.integer(forKey: rewardKey) + loadHistory() + } + + // MARK: - Referral Link + + var referralURL: URL { + URL(string: "https://chronomind.app/ref/\(myReferralCode)")! + } + + var shareText: String { + "Try ChronoMind — the time management app that actually works! Use my code \(myReferralCode) and we both get 1 month Pro free: \(referralURL.absoluteString)" + } + + // MARK: - Apply Referral Code + + func applyReferralCode(_ code: String) -> ReferralResult { + // Can't refer yourself + guard code != myReferralCode else { + return .failure("You can't use your own referral code") + } + + // Can only be referred once + guard referredBy == nil else { + return .failure("You've already used a referral code") + } + + // Validate code format + guard Self.isValidCode(code) else { + return .failure("Invalid referral code format") + } + + // Apply referral + referredBy = code + UserDefaults.standard.set(code, forKey: referredByKey) + + // Reward: +1 month Pro for the new user + proRewardMonths += 1 + UserDefaults.standard.set(proRewardMonths, forKey: rewardKey) + + // In production, server would also credit the referrer + // For now, just record locally + let record = ReferralRecord( + code: code, + direction: .incoming, + date: Date(), + rewardMonths: 1 + ) + referralHistory.append(record) + saveHistory() + + return .success(monthsEarned: 1) + } + + // MARK: - Record Outgoing Referral (called when server confirms) + + func recordOutgoingReferral() { + referralCount += 1 + UserDefaults.standard.set(referralCount, forKey: countKey) + + proRewardMonths += 1 + UserDefaults.standard.set(proRewardMonths, forKey: rewardKey) + + let record = ReferralRecord( + code: myReferralCode, + direction: .outgoing, + date: Date(), + rewardMonths: 1 + ) + referralHistory.append(record) + saveHistory() + } + + // MARK: - URL Handling + + func canHandleURL(_ url: URL) -> Bool { + let host = url.host ?? "" + return (host == "chronomind.app" || host == "chronom.ind") && url.path.hasPrefix("/ref/") + } + + func extractCode(from url: URL) -> String? { + let path = url.path + if path.hasPrefix("/ref/") { + return String(path.dropFirst(5)) + } + return nil + } + + // MARK: - Code Generation & Validation + + static func generateCode() -> String { + // Format: CM-XXXX (4 alphanumeric chars) + let chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // No O/0/1/I to avoid confusion + let random = String((0..<4).map { _ in chars.randomElement()! }) + return "CM-\(random)" + } + + static func isValidCode(_ code: String) -> Bool { + let pattern = #"^CM-[A-HJ-NP-Z2-9]{4}$"# + return code.range(of: pattern, options: .regularExpression) != nil + } + + // MARK: - Persistence + + private func loadHistory() { + guard let data = UserDefaults.standard.data(forKey: historyKey), + let decoded = try? JSONDecoder().decode([ReferralRecord].self, from: data) else { return } + referralHistory = decoded + } + + private func saveHistory() { + if let data = try? JSONEncoder().encode(referralHistory) { + UserDefaults.standard.set(data, forKey: historyKey) + } + } +} + +// MARK: - Models + +enum ReferralResult { + case success(monthsEarned: Int) + case failure(String) + + var isSuccess: Bool { + if case .success = self { return true } + return false + } + + var message: String { + switch self { + case .success(let months): + return "Referral applied! You earned \(months) month\(months == 1 ? "" : "s") of Pro free." + case .failure(let reason): + return reason + } + } +} + +struct ReferralRecord: Codable, Identifiable { + let id: String + let code: String + let direction: ReferralDirection + let date: Date + let rewardMonths: Int + + init(code: String, direction: ReferralDirection, date: Date, rewardMonths: Int) { + self.id = UUID().uuidString + self.code = code + self.direction = direction + self.date = date + self.rewardMonths = rewardMonths + } +} + +enum ReferralDirection: String, Codable { + case incoming = "incoming" // someone used your code + case outgoing = "outgoing" // you used someone's code +}