feat(growth): add referral program — unique codes, invite tracking, Pro reward months
This commit is contained in:
parent
fdcae8297a
commit
bebb566caf
192
ios/ChronoMind/Shared/Growth/ReferralManager.swift
Normal file
192
ios/ChronoMind/Shared/Growth/ReferralManager.swift
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user