Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Foundation

// MARK: - ResurrectionConciergeGenerator

/// Popup generator for the resurrection concierge experience.
/// Shows a concierge popup for members eligible for resurrection, but NOT
/// for members who are current direct depositors — matching the same check
/// used by the resurrection home widget server API.
///
/// Gating: surfaces at most once, only if the member is NOT a current
/// direct depositor and IS in the resurrection cohort.
final class ResurrectionConciergeGenerator: ScreenFlowBottomSheetPopupGenerating {

// MARK: - Constants

private enum Constants {
static let flowId = "resurrection_concierge_bottom_sheet"
static let deeplink = "chimecard://screen_flow_bottom_sheet?flowId=\(flowId)"
static let popupId = "resurrection_concierge_popup"
}

// MARK: - Dependencies

private let eligibilityChecker: ResurrectionConciergeEligibilityChecking
private let gatingStore: ConciergeGatingStoring
private let deeplinkRouter: DeeplinkRouting

// MARK: - Init

init(
eligibilityChecker: ResurrectionConciergeEligibilityChecking,
gatingStore: ConciergeGatingStoring,
deeplinkRouter: DeeplinkRouting
) {
self.eligibilityChecker = eligibilityChecker
self.gatingStore = gatingStore
self.deeplinkRouter = deeplinkRouter
}

// MARK: - ScreenFlowBottomSheetPopupGenerating

var popupId: String {
Constants.popupId
}

func shouldShowPopup(completion: @escaping (Bool) -> Void) {
guard !gatingStore.hasShownConcierge(id: popupId) else {
completion(false)
return
}

eligibilityChecker.checkEligibility { result in
switch result {
case .eligible:
completion(true)
case .ineligible, .error:
completion(false)
}
}
}

func generatePopup() -> ScreenFlowBottomSheetPopup {
ScreenFlowBottomSheetPopup(
id: popupId,
deeplink: Constants.deeplink,
onPresented: { [weak self] in
self?.markConciergeAsShown()
},
onDismissed: nil,
onDeeplinkActivated: { [weak self] in
self?.routeToBottomSheet()
}
)
}

// MARK: - Private

private func markConciergeAsShown() {
gatingStore.markConciergeShown(id: popupId)
}

private func routeToBottomSheet() {
guard let url = URL(string: Constants.deeplink) else { return }
deeplinkRouter.route(to: url)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

// MARK: - ResurrectionConciergeRegistration

/// Registers the resurrection concierge with the home screen popup system.
/// This is the entrypoint that hooks into the app's popup orchestration.
enum ResurrectionConciergeRegistration {

/// Call during app startup (e.g., in the home screen coordinator) to register
/// this concierge generator with the popup orchestrator.
static func register(
popupOrchestrator: PopupOrchestrating,
directDepositStatusProvider: DirectDepositStatusProviding,
cohortProvider: CohortProviding,
deeplinkRouter: DeeplinkRouting,
gatingStore: ConciergeGatingStoring = ConciergeGatingStore()
) {
let eligibilityChecker = ResurrectionConciergeEligibilityChecker(
directDepositStatusProvider: directDepositStatusProvider,
cohortProvider: cohortProvider
)

let generator = ResurrectionConciergeGenerator(
eligibilityChecker: eligibilityChecker,
gatingStore: gatingStore,
deeplinkRouter: deeplinkRouter
)

popupOrchestrator.registerGenerator(generator)
}
}

// MARK: - PopupOrchestrating

/// The app-level orchestrator that manages popup presentation priority and lifecycle.
protocol PopupOrchestrating {
func registerGenerator(_ generator: ScreenFlowBottomSheetPopupGenerating)
}
45 changes: 45 additions & 0 deletions ResurrectionConcierge/Sources/Gating/ConciergeGatingStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

// MARK: - Protocol

protocol ConciergeGatingStoring {
func hasShownConcierge(id: String) -> Bool
func markConciergeShown(id: String)
func resetConcierge(id: String)
}

// MARK: - Implementation

/// Persists concierge shown state so the concierge surfaces at most once per member.
final class ConciergeGatingStore: ConciergeGatingStoring {

private enum Keys {
static func shownKey(for id: String) -> String {
"concierge_shown_\(id)"
}
}

// MARK: - Dependencies

private let userDefaults: UserDefaults

// MARK: - Init

init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}

// MARK: - ConciergeGatingStoring

func hasShownConcierge(id: String) -> Bool {
userDefaults.bool(forKey: Keys.shownKey(for: id))
}

func markConciergeShown(id: String) {
userDefaults.set(true, forKey: Keys.shownKey(for: id))
}

func resetConcierge(id: String) {
userDefaults.removeObject(forKey: Keys.shownKey(for: id))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

// MARK: - Eligibility Result

enum ResurrectionConciergeEligibilityResult {
case eligible
case ineligible(reason: IneligibilityReason)
case error(Error)

enum IneligibilityReason {
case currentDirectDepositor
case notInResurrectionCohort
}
}

// MARK: - Protocol

protocol ResurrectionConciergeEligibilityChecking {
func checkEligibility(completion: @escaping (ResurrectionConciergeEligibilityResult) -> Void)
}

// MARK: - Implementation

/// Determines if a member should see the resurrection concierge.
///
/// A member is eligible when:
/// 1. They are NOT a current direct depositor
/// 2. They belong to the resurrection cohort
///
/// This mirrors the same direct depositor check used by the resurrection
/// home widget server API, which does not render for current direct depositors.
final class ResurrectionConciergeEligibilityChecker: ResurrectionConciergeEligibilityChecking {

// MARK: - Cohort Identifiers

private enum Cohorts {
static let resurrection = "resurrection_eligible"
}

// MARK: - Dependencies

private let directDepositStatusProvider: DirectDepositStatusProviding
private let cohortProvider: CohortProviding

// MARK: - Init

init(
directDepositStatusProvider: DirectDepositStatusProviding,
cohortProvider: CohortProviding
) {
self.directDepositStatusProvider = directDepositStatusProvider
self.cohortProvider = cohortProvider
}

// MARK: - ResurrectionConciergeEligibilityChecking

func checkEligibility(completion: @escaping (ResurrectionConciergeEligibilityResult) -> Void) {
directDepositStatusProvider.fetchDirectDepositStatus { [weak self] result in
switch result {
case .success(let status):
if status.isCurrentDirectDepositor {
completion(.ineligible(reason: .currentDirectDepositor))
return
}
self?.checkCohortEligibility(completion: completion)

case .failure(let error):
completion(.error(error))
}
}
}

// MARK: - Private

private func checkCohortEligibility(completion: @escaping (ResurrectionConciergeEligibilityResult) -> Void) {
cohortProvider.fetchMemberCohorts { result in
switch result {
case .success(let cohorts):
let cohortIds = Set(cohorts.map(\.id))

guard cohortIds.contains(Cohorts.resurrection) else {
completion(.ineligible(reason: .notInResurrectionCohort))
return
}

completion(.eligible)

case .failure(let error):
completion(.error(error))
}
}
}
}
35 changes: 35 additions & 0 deletions ResurrectionConcierge/Sources/Models/Protocols.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

// MARK: - DirectDepositStatusProviding

/// Provides the member's current direct deposit status.
protocol DirectDepositStatusProviding {
func fetchDirectDepositStatus(completion: @escaping (Result<DirectDepositStatus, Error>) -> Void)
}

// MARK: - DirectDepositStatus

struct DirectDepositStatus {
let isCurrentDirectDepositor: Bool
}

// MARK: - CohortProviding

/// Provides member cohort information for eligibility checks.
protocol CohortProviding {
func fetchMemberCohorts(completion: @escaping (Result<[Cohort], Error>) -> Void)
}

// MARK: - Cohort

struct Cohort {
let id: String
let name: String
}

// MARK: - DeeplinkRouting

/// Routes deeplink URLs to the appropriate destination.
protocol DeeplinkRouting {
func route(to url: URL)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

// MARK: - ScreenFlowBottomSheetPopup

/// Model representing a popup that deeplinks to a screen flow bottom sheet.
struct ScreenFlowBottomSheetPopup {
let id: String
let deeplink: String
let onPresented: (() -> Void)?
let onDismissed: (() -> Void)?
let onDeeplinkActivated: (() -> Void)?
}

// MARK: - ScreenFlowBottomSheetPopupGenerating

/// Template protocol for popup generators that deeplink to a screen flow bottom sheet.
protocol ScreenFlowBottomSheetPopupGenerating {
var popupId: String { get }
func shouldShowPopup(completion: @escaping (Bool) -> Void)
func generatePopup() -> ScreenFlowBottomSheetPopup
}
Loading