Customer Center - #509
Conversation
…rwallOptions.customerCenter
…path factories, LogScope.customerCenter
Adds SuperwallEvent.customerCenterOpen/Close/Action/SurveyResponse/RefundRequest with ObjC mirrors and InternalSuperwallEvent trackable structs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the Customer Center's 74 string keys (screens, paths, survey, purchase status, badges, stores, sections, restore, refund, update warning, duplicate subscriptions, and support) to all 41 Localizable.strings bundles, plus the bundle-backed CustomerCenterStrings.bundled(locale:). Also folds in two items deferred from Task 6's review: a dedicated customer_center_expired key so an inactive subscription with no expiration date shows "Expired" instead of "Refunded", and a regression test for nil-expiration sort ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e and restore views Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tCustomerCenter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… actor DependencyContainer.init constructed CustomerCenterManager via MainActor.assumeIsolated at the end of init, but init itself isn't @mainactor. ~20 test suites (and any host app calling Superwall.configure off-main) construct DependencyContainer off the main thread, crashing with EXC_BREAKPOINT. Fixed by deferring construction to the customerCenterManager accessor itself, now marked @mainactor and built lazily on first access; all production call sites (Superwall.presentCustomerCenter/dismissCustomerCenter/ the Objective-C variant) are already @mainactor, so this needs no assumeIsolated. Also logs a loud warning from CustomerCenterManager.makeViewModel(configuration:) when Superwall hasn't been configured yet, since CustomerCenterView/ CustomerCenterViewController route through it and would otherwise silently render a dead screen with no purchase data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a Customer Center button to the Basic and Advanced example apps, a CustomerCenter.md DocC article, and CHANGELOG entries under the already-staged 4.16.4 release (develop is ahead of master, so no version bump is needed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…doff, embedded dismiss, receipt refresh, ObjC parity Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The screen shows when the customer has no purchases on record at all — no subscriptions (active or expired), no one-time purchases, no active entitlements. An expired subscriber routes to the management screen, so "no active" described a case that never reaches here. The new name matches the hasAnyPurchases predicate that actually gates it. Renames the public noActiveScreen property, the internal screen state case, NoPurchasesScreenView, the customerCenterOpen event's screen value, the accessibility identifier, and the localization keys across all 41 locales (keys only — the displayed copy is unchanged).
The final review pass rewrote "Created by Claude" to "Created by Jordan Morgan" across the whole repo when it should have been scoped to the files this feature adds. That touched 24 pre-existing files (TestMode, V2ProductsResponse, TestStoreUser, EntitlementProcessor and several test files) that have nothing to do with the Customer Center. Restores them to their state on develop; the header fix stands only on Customer Center files.
…CustomerCenter RevenueCatUI puts a presentCustomerCenter modifier on View with every parameter after isPresented defaulted, and so did we. Verified empirically by building a target that imports SuperwallKit, RevenueCat and RevenueCatUI: with the shared name, a bare .presentCustomerCenter(isPresented:) call compiled without error and silently resolved to SuperwallKit's — Swift's solver penalises each defaulted argument it fills, and ours fills 2 against RevenueCat's 13. An existing RevenueCat customer adding SuperwallKit would have had their Customer Center silently swapped for ours, with no diagnostic. Renaming the modifier makes each resolve to its own module. Confirmed by demangling the linked symbols: presentCustomerCenter -> RevenueCatUI, presentSuperwallCustomerCenter -> SuperwallKit. Objective-C was already safe (RC* vs SWK* prefixes, so no duplicate class registration at load, which @available could not have prevented). The four shared Swift type names (CustomerCenterView, CustomerCenterViewController, CustomerCenterNavigationOptions, CustomerCenterAction) stay as they are — module qualification resolves those, and it is idiomatic Swift. Superwall.shared.presentCustomerCenter() is unchanged; it is on our own type and cannot collide.
In the default configuration, the .manageSubscription path carries the cancellation survey and leads to Apple's manage-subscriptions sheet, so its job is cancelling, not general management. "Manage subscription" overstated what the row does. The key customer_center_path_manage_subscription is unchanged since it tracks the PathType.manageSubscription case, not the displayed text — only the string values change, across englishStrings and all 41 Localizable.strings locales. Each locale uses its subscription-termination verb (e.g. German "kündigen", French "résilier", Japanese "解約", Dutch "opzeggen", Italian "disdire", Croatian "otkazati", Danish/Norwegian "si/sei opp") rather than reusing customer_center_cancel's dialog-dismiss word, except where a language genuinely shares one verb for both senses (e.g. Spanish, Portuguese, Polish, Czech, Vietnamese, Thai, Korean, Chinese), confirmed against each file's existing register.
The ManageSubscriptionsSheet modifier chose its branch on `groupId`, which is derived from viewModel.sheet and therefore turns non-nil in the same update that flips isPresented to true. SwiftUI treats the two branches as different view identities, so that update tore down the modifier that was about to present and built a different one — Apple's sheet never appeared. Reported from a device run: answering the cancellation survey dismissed the survey and returned to the Customer Center with nothing else shown. Branch on #available only, which is constant for the process, and pass the group id through as a value. The sheet is never presented while groupId is nil, so the empty-string fallback is unreachable in practice. Not coverable by the existing tests: the view model already asserts the state transition (sheet == .manageSubscriptions after the survey dismissal), and it still passes — the failure was entirely in the SwiftUI presentation layer, which the hostless test target cannot exercise.
A chevron promises a push onto the navigation stack. None of the action rows push: restore runs in place, cancel/change plan/refund/custom URL present sheets, and contact support leaves the app. The rows that genuinely push — "See all purchases" and the purchase detail rows — are NavigationLinks and draw their own chevron, so those are unaffected. The rows still read as tappable from the accent-coloured label, matching how action rows look elsewhere in iOS. The in-row progress indicator is kept.
…stomer Center's
Restoring from the Customer Center with no purchases showed two stacked
alerts: the SDK's paywall-worded restore-failure alert ("No Subscription
Found") on top of the Customer Center's own result alert ("No past
purchases", which is localized and offers Contact support).
tryToRestore gains a presentsFailureAlert flag, defaulting to true so the
public restorePurchases() and all paywall restores are unchanged. The
Customer Center passes false and keeps presenting its own outcome.
No automated coverage: the SDK presents that alert on the top-most view
controller via the key window, which the hostless test target has no way to
provide, so an assertion that no alert appears passes whether or not the fix
works. Verified against the reported device repro instead.
Tapping Continue flipped the flag outside a transaction, so the banner's section vanished from the list in a single frame. Wrap the change in withAnimation at the view layer, so removing the section from the list is part of the same transaction. Reduce Motion gets withAnimation(nil), which applies the change without animating. Also adds a round-trip test for the appearance accent: a UIColor passed to ColorPair is stored as hex and has to parse back into a Color for the theme to tint anything. Nothing covered that path before.
…e root view The root view's `.onDisappear` fired `dismiss()` directly, gated by an `isNavigatingWithinCustomerCenter` flag set/cleared by pushed screens' onAppear/onDisappear. In embedded mode (`usesExistingNavigation`) the host owns the navigation stack, so if it tears its stack down while a pushed screen (purchase detail / purchase history) is on top — popping to root, resetting a NavigationPath, or a long-press-Back past the Customer Center — the root view never reappears and the flag never clears. `didDismiss` and `customerCenterClose` then never fire at all. The flag was also inaccurate two pushes deep: history → purchase detail cleared it while still inside. Replaced the boolean with a visibility count on the view model: `surfaceDidAppear()`/`surfaceDidDisappear()` increment/decrement a counter, attached to every surface that can be on screen (root, purchase detail screen, purchase history, purchase detail rows — not sheets, since those present over a root that stays alive). When the count reaches zero it debounces briefly (default 0.3s, cancellable) before calling `dismiss()`, because a push/pop transition can briefly have both or neither surface on screen — one runloop turn isn't enough to tell "navigating within the Customer Center" from "actually gone". `dismiss()` keeps its `didDismiss` latch, so double-firing stays impossible regardless of how many surfaces disappear. Sheet mode and the UIKit CustomerCenterViewController are unaffected: the root view still appears/disappears exactly once for those, so `didDismiss` still fires exactly once. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Review flagged 0.3s as uncomfortably close to a UINavigationController push/pop (~0.35s). During a pop the outgoing screen's onDisappear can land before the root's onAppear, dipping the visible-surface count to zero mid-transition; if the debounce elapses in that window, didDismiss fires while the user is still inside the Customer Center. 0.6s clears it with margin. The interval only delays how soon didDismiss reaches the host, and nothing is gated on it. Tests inject a short interval, so they are unaffected.
|
PR author is not in the allowed authors list. |
There was a problem hiding this comment.
Important
The dismissal path looks broken for the primary presentation API: customerCenterDidDismiss() and the customerCenterClose event should never reach a delegate created the way the shipped example and the docs recommend. Details inline on CustomerCenterManager.swift.
Reviewed changes — full read of the 108-file diff at 2001d65, with the feature's five load-bearing files (config model, entry point, view model, path resolver, presentation builder) traced against their call sites and tests.
- Customer Center feature — a native
@available(iOS 15.0, *)self-service screen presented viaSuperwall.shared.presentCustomerCenter(),CustomerCenterView, orCustomerCenterViewController, covering restore, manage/cancel, refund, change plan, contact support, exit surveys and purchase history. - Public configuration surface —
CustomerCenterConfigurationand its nestedScreen/Path/FeedbackSurvey/Support/Appearancetypes,Codableand ObjC-bridged, resolved per-call →SuperwallOptions.customerCenter→.default. - Product logic —
CustomerCenterPathResolver(which rows appear, gated on store, active-ness, revocation, family sharing, refund window, iOS 17 change-plan availability) andPurchasePresentationBuilder(badges, status lines, per-product renewal dedupe), both table-tested. - Delegate + callbacks —
CustomerCenterDelegate, an ObjC mirror, a weak-holding adapter, and five SwiftUI.onCustomerCenter*modifiers accumulated through an environment box. - Analytics — five new
SuperwallEventcases with ObjC mirrors and parameter payloads. - Localization — 75
customer_center_*keys across all 41.lprojbundles. - Hooks outside the feature —
SuperwallOptions.customerCenter,LogScope.customerCenter,DeviceHelper.appInstallDateValue, a lazy@MainActorDependencyContainer.customerCenterManager, andTransactionManager.tryToRestore(_:presentsFailureAlert:)defaulting totrue.
A few things I checked and found clean, so they need no further attention: none of the five new events can implicitly trigger a paywall (canImplicitlyTriggerPaywall falls through to false); SuperwallOptions.encode(to:)'s explicit CodingKeys omits customerCenter, so nothing new is sent to the backend; every locale has all 75 keys with matching %@ counts and no stray %, so there is no String(format:) crash or argument-reordering hazard; and every StoreKit/SwiftUI API used is available at or below the version its guard asserts (verified against Apple's DocC JSON, including Mac Catalyst).
⚠️ Nothing exercises the seam between CustomerCenterManager and the view model's dismissal
The suite is genuinely good — the resolver and builder tables read like a spec, and the visibility-count tests pin the counter arithmetic exactly. But every dismissal test drives the view model directly with a live instance in hand, and CustomerCenterManagerTests separately asserts the delegate is released the instant the manager's cleanup runs. No test spans both, which is precisely why the inline CustomerCenterManager.swift finding passes CI today.
Technical details
# Add coverage for the manager → view model dismissal handoff
## Affected sites
- `Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift:331-400` — drives `surfaceDidAppear`/`surfaceDidDisappear` on a view model the test itself retains; the debounce always finds a live `self`.
- `Tests/SuperwallKitTests/CustomerCenter/CustomerCenterManagerTests.swift:46-88` — asserts `weakDelegate == nil` immediately after `onDismiss` runs, with no view model in play.
- `Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterDelegateAdapterTests.swift:33-47` — holds a strong local delegate for the whole test, so the adapter's `weak` capture is never observed going nil.
## Required outcome
- One test that presents through `CustomerCenterManager`, triggers the same dismissal cleanup the VC's `viewDidDisappear` would, waits past `dismissDebounceInterval`, and asserts the delegate's `customerCenterDidDismiss()` actually ran and a `customerCenterClose` event was tracked.
- The test must fail against the current code.
## Suggested approach (optional)
- Inject a short `dismissDebounceInterval` into the view model the manager builds (a test hook alongside `presentsAnimated`), keep a strong local reference to the probe delegate so the assertion is about delivery rather than lifetime, and assert on a mock tracker.
## Open questions for the human
- Is the hostless test target able to reach `viewDidDisappear` at all, or does this need to go through `presentedControllerForTesting?.onDismiss?()` plus an explicit `surfaceDidDisappear()` to simulate SwiftUI's teardown?ℹ️ Two decisions the diff raises but can't settle
- Version stayed at 4.16.4. Following
CLAUDE.mdliterally is correct here (a release was already staged ondevelop), but this adds a substantial new public API surface —CustomerCenterConfiguration,CustomerCenterDelegate,CustomerCenterView,CustomerCenterViewController, fiveSuperwallEventcases, aViewmodifier — under a patch version. Worth an explicit maintainer decision rather than falling out of the changelog rule. - 41 first-draft translations. Key parity and format specifiers are clean across every
.lproj(I checked all 75 keys in all 41 files), so there's no correctness hazard; the remaining risk is purely wording, and the author's suggestion to route it through localization before release seems right.
ℹ️ Nitpicks
Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift:44—ForEach(others.prefix(2))silently truncates non-subscription purchases. WithshowsPurchaseHistory == falsethere's no "See all purchases" row, so anything past the first two becomes unreachable.Tests/SuperwallKitTests/CustomerCenter/ViewModel/CustomerCenterViewModelTests.swift:319— comment says "the production default, 0.3s"; the default is now0.6(CustomerCenterViewModel.swift:67).
Claude Opus | 𝕏
| controller.onDismiss = { [weak self] in | ||
| self?.presentedController = nil | ||
| self?.retainedDelegate = nil | ||
| onDismiss?() | ||
| } |
There was a problem hiding this comment.
This clears the only strong reference to the delegate the moment viewDidDisappear fires, but CustomerCenterViewModel.dismiss() — the sole emitter of callbacks.didDismiss and the customerCenterClose event — doesn't run until the 0.6s visibility debounce elapses. By then the adapter's weak swiftDelegate is nil, so customerCenterDidDismiss() is silently dropped for exactly the usage the docs and CustomerCenterExampleDelegate recommend ("safe to create a fresh instance each time you present").
Technical details
# `didDismiss` and `customerCenterClose` are dropped on the `presentCustomerCenter` path
## Affected sites
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift:136-140` — `onDismiss` clears `retainedDelegate` synchronously.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:83-88` — `viewDidDisappear` invokes that closure at dismissal completion.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterDelegateAdapter.swift:18-19,58-61` — the adapter holds the delegate `weak`, so once `retainedDelegate` is cleared the callback no-ops.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:337-343,345-352` — `dismiss()` only runs after `dismissDebounceInterval` (default `0.6`, `:67`), and the debounce `Task` captures `[weak self]`.
## Two independent failures, same root cause
1. **Delegate already released.** Certain: `retainedDelegate` is the only strong reference the SDK promises, and it is gone ~0.6s before the callback fires.
2. **View model likely deallocated.** `CustomerCenterManager.presentedController` is `weak` (`:16`), so UIKit's release of the dismissed VC deallocates the VC, its `UIHostingController`, and the view model. The debounce `Task`'s `guard let self` then fails and `dismiss()` never runs at all — meaning `customerCenterClose` is never tracked either, on any presentation path where the view model doesn't outlive the debounce.
## Required outcome
- `customerCenterDidDismiss()` reaches a delegate passed to `Superwall.shared.presentCustomerCenter(delegate:)`.
- `customerCenterClose` is tracked exactly once per presentation, on both the UIKit/manager path and the SwiftUI sheet path.
- The existing embedded-navigation behaviour (`usesExistingNavigation`, push/pop within the Customer Center must not fire a dismissal) is preserved.
## Suggested approach (optional)
- The debounce exists only because embedded mode can't distinguish a nav transition from a teardown. A `CustomerCenterViewController` being dismissed knows definitively: have `viewDidDisappear` call `viewModel.dismiss()` directly (it's already idempotent via the `didDismiss` latch) *before* invoking `onDismiss`, and leave the debounce as the embedded-mode-only fallback.
- Whatever the mechanism, clear `retainedDelegate` after the view model has fired, not before.
## Open questions for the human
- Should the SwiftUI sheet path get the same treatment, or is `@StateObject` teardown there slow enough that the 0.6s debounce reliably wins? That one I couldn't determine from the code alone.| <TestableReference | ||
| skipped = "NO"> | ||
| skipped = "NO" | ||
| parallelizable = "NO"> |
There was a problem hiding this comment.
This file is regenerated by xcodegen from project.yml, which scripts/build.sh, scripts/test.sh and the CI xcodegen-action all run — so this hand edit disappears on the next generation and the suite silently goes back to running in parallel. It needs to be parallelizable: false under targets.SuperwallKit.scheme.testTargets in project.yml instead. Separately, switching the whole suite to serial execution is a repo-wide policy change worth stating explicitly (which suites were racing?), since it costs CI time for every future test too.
| var dateFormatter: DateFormatter = { | ||
| let formatter = DateFormatter() | ||
| formatter.dateStyle = .medium | ||
| formatter.timeStyle = .none | ||
| return formatter | ||
| }() |
There was a problem hiding this comment.
This formatter (and the one in PurchaseDetailRows) uses Locale.current, while every string goes through CustomerCenterStrings.bundled() → LocalizationLogic.localizedBundle(nil), which resolves against deviceHelper.preferredLocaleIdentifier. An app setting SuperwallOptions.localeIdentifier gets translated copy with system-locale dates. CustomerCenterEnvironmentProviding.locale (CustomerCenterDependencies.swift:46,157) already carries exactly the right value but is never read by anything — wiring it into both formatters closes the gap and removes the dead protocol requirement.
| hasher.combine(support) | ||
| hasher.combine(appearance) |
There was a problem hiding this comment.
Support, Appearance and ColorPair override isEqual but not hash, so these two lines mix identity hashes into a value-equality hash: two configurations that compare equal (e.g. .default twice, or an instance and its Codable round-trip) produce different hashes. Nothing in the SDK hashes this type today, but it's a public NSObject and so Hashable to consumers. Screen, Path, FeedbackSurvey and Option already pair both overrides — the three stragglers should match.
| osVersion: env.osVersion, | ||
| deviceModel: env.deviceModel, | ||
| sdkVersion: env.sdkVersion, | ||
| activeEntitlementIds: active, |
There was a problem hiding this comment.
active is purchases.filter(\.isActive).compactMap(\.productId) — product identifiers, not entitlement identifiers — but SupportEmailComposer renders the field as - Entitlements:. Either populate it from customerInfo.entitlements or rename the field and label to product IDs, so support isn't reading mislabelled diagnostics.

Changes in this pull request
Adds the Customer Center: a native, self-service subscription-management screen inside the SDK. One call presents it:
It shows the customer's subscriptions and purchases and lets them restore purchases, open Apple's manage-subscriptions sheet, request a refund, change plans, contact support, answer an exit survey, and browse purchase history. There's a SwiftUI view (
CustomerCenterView), a UIKit view controller (CustomerCenterViewController), an Objective-C surface, a delegate, five SwiftUI callback modifiers, five new analytics events, and strings for all 41 locales.Everything is configured in code via
SuperwallOptions.customerCenter. The configuration model isCodableand deliberately shaped so a future dashboard/backend can serve the same JSON without changing the public API — resolution order is per-call argument → options →.default.Zero-config gives a working screen: with an active App Store subscription you get the subscription card, Restore, Change plan, Request a refund, Cancel subscription (with a cancellation survey), See all purchases, and Account details. The only row that needs configuration is Contact support, which is hidden unless a support email is set.
Requires iOS 15+. The SDK's deployment target is unchanged at iOS 13 — the Customer Center symbols are
@available(iOS 15.0, *), because every StoreKit API it drives is iOS 15+ anyway.Reviewing this
108 files is a lot, but five files are the whole feature — the rest is SwiftUI, tests, and localization:
CustomerCenter/Models/CustomerCenterConfiguration.swift— the entire public surface. Start here.Superwall+CustomerCenter.swift— the entry point (~100 lines).CustomerCenter/ViewModel/CustomerCenterViewModel.swift— state, flows, event emission.CustomerCenter/Logic/CustomerCenterPathResolver.swift— which actions appear when. This is the product logic.CustomerCenter/Logic/PurchasePresentationBuilder.swift— badges, status lines, renewal dedupe.For 4 and 5, the table-driven tests read like a spec and are the fastest way in. Alternatively the commits are in dependency order, tests first, one concept each:
git log --reverse --patch <base>..HEAD -- Sources/SuperwallKit/CustomerCenter.Changes outside
CustomerCenter/are all small, necessary hooks:SuperwallOptions(+2 lines),LogScope(+1 case),DeviceHelper(+1 internal accessor),DependencyContainer(lazy@MainActormanager), the three analytics files (5 new event cases, purely additive),SuperwallKit.md, and one defaulted parameter on a shared test fixture.TransactionManagergains apresentsFailureAlertflag defaulting totrue, so paywall restore behaviour is bit-identical.Deliberately out of scope
Promotional / win-back retention offers (they need server-side signature generation), remote dashboard configuration, support tickets, virtual currencies, and the Android / Flutter / React Native bridges.
Decisions worth a second opinion
developwas already ahead ofmaster(4.16.3), so perCLAUDE.mdthe CHANGELOG entries went into the existing staged section rather than bumping again. New public API arguably warrants 4.17.0 — reviewer's call; it's a three-file change.SWK-prefixed against theirRC-prefixed ones, so no duplicate class registration. One real collision was found and fixed — both SDKs putpresentCustomerCenteron SwiftUI'sViewwith everything afterisPresenteddefaulted, and Swift silently resolved the bare call to ours (its solver penalises each defaulted argument it fills; ours fills 2, theirs 13), which would have hijacked an existing RevenueCat customer's screen with no error. Our modifier is nowpresentSuperwallCustomerCenter. Verified by building a target that imports SuperwallKit, RevenueCat and RevenueCatUI together and demangling the linked symbols.Known gaps
Support/Appearance/ColorPairoverrideisEqualwithouthash, matching existing convention inCustomerInfoand friends.dismiss(completion:)'s UIKit-driven completion path and the SDK's alert-suppression have no automated coverage: the hostless test target cannot complete modal presentations or present aUIAlertController, so such assertions would pass whether or not the code works. Both were verified manually instead.Testing
1006 tests across 110 suites, all passing. Every task was reviewed by someone other than its author, with a cross-cutting review over the whole branch.
A full manual pass was also run on device across 20 scenarios — purchase, cancel, refund, expiry, billing retry, empty state, restore, delegate callbacks, code-driven configuration — and it found two real bugs that static review did not:
ManageSubscriptionsSheetbranched ongroupId, which turned non-nil in the same update that flippedisPresentedtrue; SwiftUI tore down the modifier that was about to present. Now branches only on#available.Also fixed from that pass: disclosure chevrons were removed from action rows (a chevron promises a push, and none of those rows push), and the update banner now animates out instead of blinking.
Checklist
#if os(iOS)and available on Catalyst 15+)CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.swiftlintin the main directory and fixed any issues. (10 violations, all pre-existing ondevelop; zero added.)Documentation.docc/CustomerCenter.md) and linked fromSuperwallKit.md. The online docs page still needs writing.cc @yusuftor @jakemor @anglinb