feat(growth): add referral program — unique codes, invite tracking, Pro reward months

This commit is contained in:
saravanakumardb1 2026-02-27 22:59:02 -08:00
parent fdcae8297a
commit bebb566caf

View File

@ -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
}