Skip to main content
ZSMigrateTipView is a self-contained view that prompts eligible users to switch from Apple billing to direct billing at a discount. It manages its own visibility, checkout flow, and dismissal state. You just add it to your layout.

Usage

ZSMigrateTipView(
    userId: currentUser.id,
    stripeCustomerId: "cus_abc123",                  // optional
    backgroundColor: .black,                        // optional
    titleFont: .system(size: 20, weight: .bold),    // optional
    bodyFont: .system(size: 16),                     // optional
    ctaFont: .system(size: 17, weight: .bold),       // optional
    borderColor: .white.opacity(0.2),                // optional
    onEvent: { event in                              // optional
        switch event {
        case .ctaTapped:              print("user tapped CTA")
        case .checkoutCompleted:      print("web checkout succeeded")
        case .appleSubscriptionManagementOpened:
                                      print("opened Apple sub management")
        case .migrationCompleted:     print("full migration done")
        case .dismissed:              print("view dismissed")
        }
    }
)
All three use the same native implementation under the hood. Only userId is required — everything else has sensible defaults.
ZSMigrateTipView targets users switching from Apple StoreKit subscriptions to direct billing. On Android, the equivalent switch and save flow from Google Play Billing is not yet supported by this component.

Automatic Visibility

The view decides whether to render itself based on the user’s entitlement state. You never need to write conditional logic around it.
ConditionBehavior
User has a StoreKit subscription that matches the dashboard-configured product✅ View appears (user is eligible to switch and save)
User already has a web checkout entitlement🚫 View is hidden (already switched)
User has no active subscription🚫 View is hidden (not a switch and save candidate)
User previously dismissed the view🚫 View is hidden (dismissal persisted via UserDefaults)
User has not been subscribed long enough🚫 View is hidden (e.g., user subscribed less than 90 days ago)
User is not in the rollout cohort🚫 View is hidden (phased rollout has not reached this user yet)
The minimum subscription age and rollout percentage are configured in the dashboard switch and save campaign. For example, you can set the minimum to 90 days so only users subscribed for at least 90 days see the view, and roll out to 10% of eligible users first before going to 100%. This lets you target loyal, high-retention users and control exposure as you ramp up. On mount, the view checks the minimum subscription age and evaluates the rollout cohort from your dashboard config. If none of the conditions for showing are met, it renders as an EmptyView with no layout impact.

Placement

The view works anywhere a standard view does. Settings screens and account pages tend to convert best.
List {
    Section("Account") {
        // your existing rows...
    }

    ZSMigrateTipView(userId: user.id, backgroundColor: .blue)
        .listRowInsets(EdgeInsets())
        .listRowBackground(Color.clear)
}

Parameters

All platforms

ParameterTypeDefaultDescription
userIdStringRequiredThe user identifier passed to the checkout backend.
stripeCustomerIdString?nilOptional existing Stripe Customer ID (cus_xxx). When provided, the checkout is attached to this customer instead of creating a new one. Useful for a unified Billing Portal view.
backgroundColorColor (SwiftUI, Flutter) / string (React Native).black / "#000000"Background color for the card. Pass a Color in SwiftUI and Flutter, or a hex string in React Native.

SwiftUI only

These optional parameters are available on the native SwiftUI view. React Native and Flutter wrappers use the defaults.
ParameterTypeDefaultDescription
titleFontFont?System boldCustom font for the title text.
bodyFontFont?System defaultCustom font for the body/message text.
ctaFontFont?System boldCustom font for the CTA button text.
borderColorColor?nil (no border)Border color applied as a rounded rectangle stroke on the card.
onEvent((MigrationTipView.Event) -> Void)?nilClosure invoked when a lifecycle event occurs. See Event Types below.
The button copy, discount amount, and messaging are controlled from the dashboard switch and save campaign. No code change needed.

Event Types

The onEvent callback receives a MigrationTipView.Event value as the user progresses through the migration flow:
EventWhen it fires
.ctaTappedThe user tapped the main CTA button to begin checkout.
.checkoutCompletedThe web checkout completed successfully. The user now has a web entitlement, but their Apple subscription is still active.
.appleSubscriptionManagementOpenedThe user opened Apple’s subscription-management sheet to cancel their Apple billing.
.migrationCompletedThe full migration flow is finished — web checkout succeeded and the Apple subscription-management sheet was shown.
.dismissedThe view was dismissed (close button, auto-dismiss after completion, or the user became ineligible).
Events fire in lifecycle order: ctaTappedcheckoutCompletedappleSubscriptionManagementOpenedmigrationCompleteddismissed. Not every event is guaranteed — for example, a user who dismisses the view before completing checkout will only trigger ctaTapped then dismissed.
onDismiss is deprecated but still works. It maps to onEvent and only fires on .dismissed. Migrate to onEvent for full visibility into the flow.

Experimentation

The discount, messaging, and targeting are all controlled from the dashboard, so you can iterate without shipping an app update.
SettingWhat it controls
Discount %The percentage off the web price shown in the CTA (e.g., 15% off)
TitleThe heading displayed in the tip view
MessageThe body text explaining the offer
Minimum subscription ageMinimum subscription age in days before the view appears
Rollout %Percentage of eligible users who see the view
Adjust these at any time to test different offers. For example, start with a 10% discount at 180 days tenure rolled out to 10% of users, then broaden to 15% off at 90 days for 100% of users and compare conversion rates in your dashboard analytics.

Debugging

The view may be hidden during development because it was previously dismissed. Reset the persisted state:
ZSMigrationManager.resetDismissedState()
ZSMigrateTipView.resetMigrateTipState() still works but is deprecated. Use ZSMigrationManager.resetDismissedState() instead.
The view logs its visibility decisions to the console with the prefix [ZSMigrateTipView Business Logic], including which condition caused it to show or hide.

Next Steps