Skip to content

Customer Center - #509

Open
DreamingInBinary wants to merge 50 commits into
developfrom
customer-management-portal
Open

Customer Center#509
DreamingInBinary wants to merge 50 commits into
developfrom
customer-management-portal

Conversation

@DreamingInBinary

@DreamingInBinary DreamingInBinary commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Changes in this pull request

Adds the Customer Center: a native, self-service subscription-management screen inside the SDK. One call presents it:

Superwall.shared.presentCustomerCenter()

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 is Codable and 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:

  1. CustomerCenter/Models/CustomerCenterConfiguration.swift — the entire public surface. Start here.
  2. Superwall+CustomerCenter.swift — the entry point (~100 lines).
  3. CustomerCenter/ViewModel/CustomerCenterViewModel.swift — state, flows, event emission.
  4. CustomerCenter/Logic/CustomerCenterPathResolver.swift — which actions appear when. This is the product logic.
  5. 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 @MainActor manager), the three analytics files (5 new event cases, purely additive), SuperwallKit.md, and one defaulted parameter on a shared test fixture. TransactionManager gains a presentsFailureAlert flag defaulting to true, 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

  • Version stayed at 4.16.4. develop was already ahead of master (4.16.3), so per CLAUDE.md the 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.
  • "Customer Center" is also RevenueCat's product name, chosen for discoverability and migration parity. Verified there are no symbol clashes: our Objective-C classes are SWK-prefixed against their RC-prefixed ones, so no duplicate class registration. One real collision was found and fixed — both SDKs put presentCustomerCenter on SwiftUI's View with everything after isPresented defaulted, 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 now presentSuperwallCustomerCenter. Verified by building a target that imports SuperwallKit, RevenueCat and RevenueCatUI together and demangling the linked symbols.
  • Refund stays available on expired subscriptions. Apple permits it, but it's a product call.
  • "Cancel subscription", not "Manage subscription". In the default configuration that row carries the cancellation survey and opens Apple's cancel sheet, so the old label overstated it. Each locale uses its subscription-termination verb (German kündigen, French résilier, Japanese 解約) rather than the dialog-dismiss word.
  • The 41 locale translations are first-draft with no native-speaker review. Worth routing through localization before release.

Known gaps

  • The internal navigation flag was replaced with a visibility count, which closed the embedded-mode dismissal hole. Row ordering is nondeterministic when two products tie on both active-ness and expiry date (contents are deterministic). Support/Appearance/ColorPair override isEqual without hash, matching existing convention in CustomerInfo and 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 a UIAlertController, 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:

  • Apple's manage-subscriptions sheet never appeared after the cancellation survey. ManageSubscriptionsSheet branched on groupId, which turned non-nil in the same update that flipped isPresented true; SwiftUI tore down the modifier that was about to present. Now branches only on #available.
  • Restoring with no purchases showed two stacked alerts — the SDK's paywall-worded failure alert on top of the Customer Center's own.

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

  • All unit tests pass. (1006 tests / 110 suites)
  • All UI tests pass. — N/A, this repo has no UI test target.
  • Demo project builds and runs on iOS. (Basic and Advanced; manually exercised on iPhone 17 simulator)
  • Demo project builds and runs on Mac Catalyst. (framework builds for Catalyst; the Customer Center is #if os(iOS) and available on Catalyst 15+)
  • Demo project builds and runs on visionOS. — not verified; CI doesn't build visionOS.
  • I added/updated tests or detailed why my change isn't tested. (See "Known gaps" for the two paths the hostless test target cannot cover.)
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues. (10 violations, all pre-existing on develop; zero added.)
  • I have updated the SDK documentation as well as the online docs. — DocC article added (Documentation.docc/CustomerCenter.md) and linked from SuperwallKit.md. The online docs page still needs writing.
  • I have reviewed the contributing guide

cc @yusuftor @jakemor @anglinb

Known limitation: web prices assume USD

/v1/products stamps currency: "usd" unconditionally (products-public.ts); the real per-product currency lives in Product.metadata.__superwall_price_currency and isn't loaded on that path. So a non-USD web subscription renders as dollars here. The V2 products API already resolves it correctly via ProductMapper.normalizeCurrency, so the fix is server-side. Shipping as-is on the assumption app-to-web is US-only today.

DreamingInBinary and others added 29 commits August 20, 2026 13:24
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.
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR author is not in the allowed authors list.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The cover-veto that closes the last review's dismissal hole depends on SwiftUI delivering onDisappear inside super.viewDidDisappear(_:) — an ordering Apple documents as view-type-dependent and ties to no UIKit callback. If it lands a turn later, cancelPendingDismissal() cancels a task that does not exist yet and the original bug survives unchanged. Details inline on CustomerCenterViewController.swift:192.

Reviewed changes — the single commit since the prior pullfrog review at a5b8fc1: 28691d5, which answers all three inline findings from that review.

  • Vetoed the debounced dismissal when a pushed controller is merely coveredviewDidDisappear's non-teardown branch now calls the new CustomerCenterViewModel.cancelPendingDismissal(), and a new didMove(toParent:) override delivers the teardown for a controller that was already covered when it got popped (UIKit gives it no second disappearance). Both go through a latched deliverDismissal(), since an ordinary pop is both a disappearance and a removal.
  • Stopped writing the host's interactivePopGestureRecognizer.isEnabled — the forced = true is gone, and the delegate capture gained the == nil idempotency guard its navigation-bar sibling already had. pushedRoundTripsTheInteractivePopGesture pins the round trip for a host that starts with the gesture both enabled and disabled.
  • Made the host's pop gesture stand down inside the Customer Center's own stack — the three drill-down surfaces now call surfaceDidAppear(isPushed:)/surfaceDidDisappear(isPushed:), feeding a separate pushedSurfaceCount that InteractivePopGestureDelegate consults, so the two nested edge-pans no longer race for the same swipe.
  • Documented that a host-constructed controller is independent of the SDK's entry points — a DocC > Important: note stating presentCustomerCenter will stack a second Customer Center over a pushed one and dismissCustomerCenter does nothing to it. This settles the design question the prior review's body section raised.
  • Extracted the support-email extension into CustomerCenterViewModel+Support.swift — a pure move, with dependencies and activeEntitlementIds relaxed from private to internal because private is file-scoped, plus the matching .pbxproj entries.
  • Added a dismissDebounceInterval hook to the test fixture and three tests covering the late-dismissal, gesture round-trip and nested-stack arbitration paths.

ℹ️ A covered Customer Center that goes away with its container now reports nothing

The veto trades a false positive for a false negative on one path. If a host pushes the Customer Center inside its own UINavigationController, pushes another screen over it, and then closes the whole flow by dismissing that navigation controller, the buried controller gets no second viewDidDisappear (it already disappeared, and that disappearance was vetoed) and no didMove(toParent: nil) (its containment never changes — the container is dismissed, not restructured). deliverDismissal() never runs, so customerCenterDidDismiss() and customerCenterClose never fire. Before this commit the debounce delivered that case — prematurely, but it delivered it.

Technical details
# A covered Customer Center torn down with its container delivers no dismissal

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:192` — the cover branch cancels the only mechanism that would have fired for a controller that never gets another lifecycle callback.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:198-207``didMove(toParent:)` catches removal from a container, but dismissing a presented container is not a containment change, so this does not fire for a controller buried inside it.
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift:136-141` — on the manager's own modal path the same shape also leaves `retainedDelegate` retained, since `onDismiss` is what clears it. Only a `.fullScreen` cover reaches this; a `.pageSheet` over a `.pageSheet` produces no disappearance at all.

## Required outcome
- A Customer Center that is covered and then destroyed without a further appearance callback still delivers exactly one `customerCenterDidDismiss()` / `customerCenterClose`, and the manager's `retainedDelegate` is still released.

## Suggested approach (optional)
- Deallocation is the one signal that path does produce. A `deinit` on `CustomerCenterViewController` that runs `deliverDismissal()`'s cleanup when `hasDeliveredDismissal` is still `false` would cover it, if the `@MainActor` hop can be made safe there.

## Open questions for the human
- Is a host-owned Customer Center inside a modally-presented navigation flow a shape you intend to support, or is the DocC recommendation narrow enough that this is acceptable?

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:60hasDeliveredDismissal never resets, and neither do didDismiss (CustomerCenterViewModel.swift:55) or hasTrackedOpen (:54). A host that retains one controller and pushes it again on each visit — which the new DocC section's settings-screen shape invites — gets no customerCenterOpen and no dismissal callbacks from the second visit onwards. The manager builds a fresh controller and view model per present, so only host-owned instances are affected; worth a sentence in the DocC section saying an instance is single-use.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// case, which has no way to tell a cover from a teardown; here we know, so veto it. Left to
// fire it would deliver `customerCenterDidDismiss()` and track `customerCenterClose` while
// the screen sits on the back stack, and latch, silencing the genuine teardown later.
viewModel.cancelPendingDismissal()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cancels a task that may not exist yet. It runs after super.viewDidDisappear(animated) on the assumption stated in the comment above — that super has already driven SwiftUI's onDisappear into surfaceDidDisappear() — but Apple documents onDisappear's exact moment as depending on the view type and ties it to no UIKit callback. If SwiftUI delivers it a runloop turn later, the veto is a no-op, the debounce arms unopposed, and the premature customerCenterDidDismiss() + customerCenterClose (and the latch that then silences the genuine teardown) is exactly the bug the last review flagged.

Technical details
# Make the cover-veto independent of when SwiftUI delivers `onDisappear`

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:192``viewModel.cancelPendingDismissal()` after `super.viewDidDisappear(animated)`.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:374-377``cancelPendingDismissal()` only cancels the current `dismissDebounceTask`; it has no effect on one armed afterwards.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift:111``.onDisappear { viewModel.surfaceDidDisappear() }`, the arming side of the race.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:379-386``dismiss()` latches on `didDismiss`, so a single premature fire is permanent.

## Required outcome
- Covering a `.pushed` Customer Center must not deliver a dismissal regardless of whether SwiftUI's `onDisappear` runs before or after `viewDidDisappear` returns.

## Suggested approach (optional)
- Turn the veto into state the debounce consults at the moment it fires rather than a one-shot cancel: a `dismissalVetoed` flag set by `cancelPendingDismissal()`, cleared by `surfaceDidAppear(isPushed:)` (a genuine reappearance retracts it), and checked with a `guard !dismissalVetoed else { return }` immediately before `dismiss()` in the debounce task.

## Open questions for the human
- If this was verified on device for the cover case, which ordering did you observe? That would settle it without a code change, and is worth recording in the comment either way.

// Covered by the host's own screen.
navigation.pushViewController(UIViewController(), animated: false)
spinRunLoop(timeout: 1) { controller.viewIfLoaded?.window == nil }
controller.viewDidDisappear(false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This manual call lands after spinRunLoop has already settled, so it forces the veto to run last — the one ordering production cannot guarantee. The test does still catch "no veto at all", but it passes either way on the race, and nothing in the suite establishes that SwiftUI's onDisappear fires under this hostless harness in the first place (hostPopGestureDefersToTheInnerStack:302 and every CustomerCenterViewModelTests case drive the view model directly instead). Arming the debounce explicitly after the veto pins the behaviour deterministically and fails against the current implementation.

Suggested change
controller.viewDidDisappear(false)
controller.viewDidDisappear(false)
// SwiftUI is not documented to deliver `onDisappear` inside `super.viewDidDisappear`, so
// simulate it landing after — the ordering the veto has to survive.
controller.viewModel.surfaceDidDisappear()

DreamingInBinary and others added 2 commits August 27, 2026 12:08
The update banner only fired when `latestAppVersion` was kept current by hand, so it went quiet
the moment a release shipped without someone editing config. It now finds the published version
itself via Apple's public lookup endpoint, cached for 24 hours.

The comparison stays "installed is older than published" rather than "differs from published".
A TestFlight or internal build is normally numbered *above* the App Store, so equality would tell
every tester to update — and send them to an older build. Calendar versions order correctly under
the same numeric comparison, since they're monotonic tuples like semantic ones.

The lookup is skipped entirely on TestFlight, sandbox and simulator builds, when the host set
`latestAppVersion` (which stays authoritative), and when `checksAppStoreForUpdates` is off. Any
failure — offline, no listing, unparseable version — hides the banner and logs.

Public rather than the App Store Connect API: Connect authenticates with a signed JWT, and the
key that signs it can't ship in a client.

`Support` gains a hand-written decoder so configuration JSON written before the flag existed
still decodes, which matters for the dashboard-served config this model is shaped for.

Since the version arrives after the screen has loaded, the banner's insertion is animated rather
than appearing from nowhere, honouring Reduce Motion.

Splits `Appearance` and the update-banner logic into their own files to stay under the file and
type length limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The App Store lookup reports a new version the moment it goes live, but Apple rolls releases out
over seven days — so early in a release some customers are told to update to a build they can't
install yet. Accepted rather than solved, but it was only alluded to in a property comment.
Now stated where someone hits it: the lookup type, the DocC article, and the changelog, each with
the way out (set `latestAppVersion`, or turn the lookup off).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new App Store lookup ships two defects the added tests can't see: the TestFlight/sandbox/simulator skip that the CHANGELOG and DocC both promise cannot hold on iOS 15 — the Customer Center's own minimum — and customerCenterOpen is now tracked behind the network round trip, so it can fire after customerCenterClose.

Reviewed changes — the single commit since the prior pullfrog review at 28691d5: 0b90c4d, which teaches the update banner to find the published version itself.

  • Added AppStoreVersionLookup — a CustomerCenterAppStoreVersionProviding struct that queries https://itunes.apple.com/lookup for results[0].version, caches it for 24 hours in UserDefaults.standard, and resolves every failure to nil so the banner just stays hidden. bundleId, regionCode, defaults, session and now are all injectable.
  • Added Support.checksAppStoreForUpdates, defaulting to true — with a hand-written init(from:) and a private CodingKeys so configuration JSON written before the key existed still decodes, plus matching isEqual/hash updates. Support is final, so the non-required decoder is legal and encode(to:) stays synthesised.
  • Extracted the banner logic into CustomerCenterViewModel+UpdateBanner.swiftrecomputeUpdateBanner() replaces the inline expression in apply(customerInfo:), latestKnownAppVersion resolves configured-then-fetched, and refreshAppStoreVersion() runs at most once per presentation behind four guards. showsUpdateBanner and three flags lost private because private is file-scoped.
  • Animated the banner's arrivalManagementScreenView gained .animation(_:value: viewModel.showsUpdateBanner), nil'd under accessibilityReduceMotion, since the banner can now land a beat after first paint.
  • Extracted Appearance/ColorPair into CustomerCenterConfiguration+Appearance.swift — a pure move that let the swiftlint:disable type_body_length come off the original file.
  • Added AppStoreUpdateCheckTests — 13 cases covering the ahead/behind/equal comparison, calendar versions, all four skip conditions, the failed lookup, once-per-presentation, the parseVersion table and the legacy-JSON decode.
  • Added DesignReviewSnapshots — a 533-line PNG generator for design review, dormant unless CUSTOMER_CENTER_SNAPSHOT_DIR is set, asserting nothing.
  • Documented the behaviour — a DocC "Warning customers about old versions" section and one ### Enhancements CHANGELOG bullet.

ℹ️ An outbound request to a third-party endpoint is on by default with no signal at the call site

checksAppStoreForUpdates defaults to true, so every app that adopts the Customer Center starts sending its bundle identifier to itunes.apple.com on first presentation, and the only place that is stated is the DocC article. That may well be the right default for the feature to be useful zero-config, but it is a distribution-wide policy call rather than a per-app one, and it interacts with a documented throughput ceiling on that endpoint.

Technical details
# Decide explicitly whether the App Store lookup should be opt-in or opt-out

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift:271,277``checksAppStoreForUpdates` is declared and defaulted to `true`.
- `Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:110-119` — the request carries `Bundle.main.bundleIdentifier` and the device region to a host outside Superwall's control.
- `Sources/SuperwallKit/Documentation.docc/CustomerCenter.md` — the "Warning customers about old versions" section is the only place the default is described.

## Required outcome
- A recorded decision on the default, and confidence that the aggregate request volume is acceptable. Apple's Search API documentation states a limit of "approximately 20 calls per minute" and explicitly scopes it to "search **and lookup** requests"; it does not say whether that is per-IP or global. The 24h per-device cache keeps per-user volume at roughly one request per day, but many devices behind one egress (corporate networks, carrier NAT) share whatever the scope turns out to be, and a newly published version is exactly when many users open the Customer Center at once.

## Open questions for the human
- Is a default-on request to `itunes.apple.com` acceptable for every app embedding the SDK, or should `checksAppStoreForUpdates` default to `false` and be opted into?
- What is the intended behaviour if the endpoint starts rate-limiting — the current code treats a non-2xx as "no answer" and does not cache the negative, so a throttled client retries on every presentation until it succeeds.

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:33,40 — the cache writes to UserDefaults.standard, which is the host app's defaults domain, while the SDK already has a file-backed Storage/Cache layer for exactly this. It also widens a pre-existing gap: PrivacyInfo.xcprivacy declares only NSPrivacyAccessedAPICategoryFileTimestamp (C617.1) and no NSPrivacyAccessedAPICategoryUserDefaults, even though TestMode/TestModeManager.swift:200-201 and TestMode/Alert/TestModeModalViewController.swift:159-262 already reach the same API unguarded in release builds. Pre-existing, so not this PR's to fix — but routing through Storage would keep the manifest accurate rather than adding a third call site to it.
  • Tests/SuperwallKitTests/CustomerCenter/Views/DesignReviewSnapshots.swift:1527Issue.record(Comment(rawValue: "WROTE \(written) PNGs…")) records a test failure. Whenever CUSTOMER_CENTER_SNAPSHOT_DIR is set the suite reports as failed even on a completely successful run.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift Outdated

init(
bundleId: String? = Bundle.main.bundleIdentifier,
regionCode: String? = Locale.current.regionCode,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment above calls this "the storefront to query", but Locale.current.regionCode is the device's region setting, which is independently configurable from the Apple ID's App Store country. When they disagree and the app isn't published in the device's region, the lookup returns an empty results array and the banner silently never shows. Locale.regionCode is also deprecated at iOS 16.0 and referenced unguarded here, while DeviceHelper.swift:90-96 wraps the same property in an #available(iOS 16, *) branch.

Technical details
# The `country` parameter is fed the device region, not the storefront

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:30-32` — the doc comment claims the value is the storefront and justifies itself on phased-release accuracy.
- `Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:39``regionCode: String? = Locale.current.regionCode`.
- `Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:114-116` — the value becomes `country=`. The format is right: Apple documents the parameter as ISO 3166-1 alpha-2 (<https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html>). The *source* is what's wrong.
- `Sources/SuperwallKit/CustomerCenter/Logic/AppStoreVersionLookup.swift:121-135` — the 24h cache is keyed on nothing but the two constant key strings, so a region change keeps serving the previous storefront's answer for up to a day.
- `Sources/SuperwallKit/Network/Device Helper/DeviceHelper.swift:90-96` — the repo's existing handling of the same deprecated property, `if #available(iOS 16, *) { …language.region?.identifier } else { …regionCode }`.

## Required outcome
- Either the value queried genuinely reflects the user's App Store storefront, or the doc comment stops claiming it does and the region is dropped from the request so the endpoint falls back to its documented `US` default consistently. Silently returning no answer for a mismatched region is the worst of the three, because the failure is invisible apart from a debug log.
- The unguarded reference to a deprecated property should follow the `DeviceHelper` pattern either way.

## Suggested approach (optional)
- StoreKit's `Storefront.current` (iOS 15+) is the first-party answer, but note its `countryCode` is ISO 3166-1 **alpha-3** (<https://developer.apple.com/documentation/storekit/storefront/countrycode>) while the endpoint wants alpha-2, so it needs a conversion table — which may well be more machinery than the feature warrants.
- The cheaper option: drop `country` entirely, accept the `US` default, and reword the comment. Phased-release skew is a narrow window; a permanently blank banner for anyone whose device region isn't a storefront the app ships in is not.

## Open questions for the human
- Was the region parameter added in response to an observed wrong-version report, or defensively? If defensively, dropping it is strictly simpler and strictly more reliable.

#"{"results":[{"version":""}]}"#,
"not json at all"
])
func parsesUnusableResponses(json: String) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseVersion is the only part of AppStoreVersionLookup under test — every other case in this suite goes through AppStoreVersionProviderMock. The defaults, session and now init parameters exist purely as test seams and nothing uses them, so the 24h TTL, the cache write/read round trip, the country query item and the non-2xx branch all ship unexercised. A URLProtocol stub plus a throwaway UserDefaults(suiteName:) and a fixed now would cover the lot without touching the network.

Three problems for customers who subscribed through Stripe or Paddle rather than the App Store.

Their one management row read "Cancel subscription". That label is right on the App Store path,
where the row carries the cancellation survey and opens Apple's cancel sheet, but a web
management page also changes plans and updates cards, so the label undersold it. Web
destinations now render "Manage subscription".

With no management URL configured, the row disappeared entirely — leaving someone who is paying
with no action beyond Restore. It now resolves to `.webManageUnavailable` and explains that the
link is in their emailed receipt, which is worse than a working link and much better than
nothing.

Surveys fired before handing off to the browser. The survey exists to gate an action, but a web
handoff leaves the app and its outcome is never observed, so the answer was attributed to
something we can't see the end of. Web flows now skip the survey.

Change plan and refund stay hidden for web, as they were: both are App Store-only, and a second
and third row pointing at the same management page would be noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Keeping the web management row when no management URL is configured also surfaces it on things that are not renewable web subscriptions — including manually granted entitlements, which get told to look in an emailed receipt that does not exist. Details inline on CustomerCenterPathResolver.swift:104.

Reviewed changes — the two commits since the prior pullfrog review at 0b90c4d: baa2a95 (documentation only) and d75c8ce (the web-subscription path).

  • Kept the web management row when no management URL is configured — the .manageSubscription web branch now falls back to a new ResolvedPathDestination.webManageUnavailable instead of returning nil, and tapping it presents a sheet explaining that the link is in the customer's emailed receipt.
  • Relabelled the web management rowPathsListView.title(for:) takes a ResolvedPath rather than a Path and picks customer_center_path_manage_subscription_web ("Manage subscription") over the App Store wording ("Cancel subscription") whenever the destination is web management.
  • Skipped feedback surveys on web flowsselect(_:purchase:)'s survey guard gained !resolved.destination.isWebManagement. The short-circuit happens before pendingSurvey, pendingAction or sheet are written, so nothing is stranded, and didSelectAction / customerCenterAction still fire first.
  • Added two localized keys across all 41 .lproj bundlescustomer_center_path_manage_subscription_web and customer_center_web_manage_unavailable, both present everywhere and covered by CustomerCenterStringsTests.allLocalesComplete.
  • Added WebSubscriptionPathTests and amended two existing cases — the resolver's otherStores and the view model's webManage now assert the new destination and the absent survey; both would fail if the guard were reverted.
  • Recorded the App Store lookup's phased-release caveat (baa2a95) — a CHANGELOG sentence, a paragraph on AppStoreVersionLookup's type doc and a DocC > Warning:, with no behavioural change.

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration.swift:152Path.survey's doc still reads "Optional survey shown before the action runs", and CustomerCenterDelegate.swift:21 plus the DocC example at Documentation.docc/CustomerCenter.md:114 attach cancelSurvey to manage_subscription with no caveat. The CHANGELOG records the new web exception; the API docs a developer reads afterwards do not.
  • Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift:91 — the new case renders as a bare Text(...).padding() in a full-height sheet with no title and no explicit dismiss control. It matches the .noMailApp sheet directly above it, so this is consistency rather than a regression, but a one-sentence informational message is conventionally an alert.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

return .appleManageSheet(subscriptionGroupId: sub.subscriptionGroupId ?? context.product?.subscriptionGroupId)
}
if isWebStore {
return context.webManagementURL.map { ResolvedPathDestination.webManage($0) } ?? .webManageUnavailable

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch gates only on purchase.store — unlike the App Store branch above, it checks nothing about the purchase's kind or state. While a nil webManagementURL returned nil, that gap was invisible; turning it into .webManageUnavailable makes it print definite English. The sharpest case is a manually granted entitlement: Entitlement.store is documented nil when no transaction backs it, PurchasePresentationBuilder.swift:182 defaults that to .superwall, and isWebStore accepts it — so a comped user is shown "Manage subscription" and told to find a link in a receipt they never received.

Technical details
# The web `.manageSubscription` branch admits purchases that are not renewable web subscriptions

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift:103-105` — the web branch's only condition is `isWebStore`; the App Store branch immediately above requires `sub.isActive, sub.willRenew, !sub.isRevoked, sub.expirationDate != nil, !context.isFamilyShared`.
- `Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift:74``isWebStore` is `[.stripe, .paddle, .superwall].contains(purchase?.store ?? .other)`.
- `Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift:182``store: entitlement.store ?? .superwall`. `Entitlement.store` is `nil` "if there aren't any transactions that unlock this entitlement" (`Entitlement.swift:75-78`), i.e. exactly the manually granted case, and the default lands inside `isWebStore`.
- `Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift:107``store: purchase.store` for `NonSubscriptionTransaction`, so a one-time Stripe/Paddle purchase is labelled "Manage subscription".
- `Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift:49` — passes the sole purchase to the screen-level `PathsListView` when `purchases.count == 1`, which is how the entitlement-only and non-subscription rows reach path resolution.
- `Sources/SuperwallKit/CustomerCenter/Views/ManagementScreenView.swift:35,97``PurchaseDetailScreenView` runs `PathsListView` for every subscription row with no active-ness gate, so an expired or revoked web subscription reaches it too.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift:149-154``webManagementURL` is `configuration.support.webManagementURL ?? web2appConfig?.restoreAccessURL`, so `nil` is the default for any app that hasn't configured web checkout. The newly-visible branch is the common path, not an edge case.

## Required outcome
- The web management row appears only for purchases where "manage your subscription" is a true statement, and never for a purchase with no receipt behind it.
- A test exercises the web branch with something other than an active renewing subscription. Every current case — `CustomerCenterPathResolverTests.otherStores` and all of `WebSubscriptionPathTests` — uses `isActive: true, willRenew: true, isRevoked: false`, so none of the above would have been caught.

## Suggested approach (optional)
- Mirror the App Store branch's shape: `guard let sub, sub.isActive, !sub.isRevoked else { return nil }` before the `webManagementURL` lookup. That removes the entitlement-only, non-subscription and dead-subscription rows in one line while leaving the intended case — a live Stripe/Paddle subscriber with no configured URL — exactly as this commit intends.

## Open questions for the human
- Should `.superwall` be treated as a web store here at all? It is the builder's fallback for "store unknown", so it carries both genuine Superwall web checkouts (which do email a receipt) and manually granted entitlements (which do not). Splitting the fallback from the real value may be the cleaner fix.
- Is an expired web subscription still worth a management row? Apple's equivalent keeps refund available on expired subscriptions, so there may be a deliberate answer here either way.

A subscription bought through Stripe or Paddle rendered with no price and its raw product
identifier as the title, because `products(for:)` only ever consulted StoreKit — which knows
nothing about a web product.

The price was already reachable: `/v1/products` returns it, the SDK already decodes that
response as `SuperwallProduct`, and `APIStoreProduct` already adapts one into a `StoreProduct`.
Nothing was asking. `LiveProductsProvider` now falls back to the catalogue for any identifier
StoreKit didn't resolve, so those cards show a price and a renewal line that quotes it.

Failure is advisory: if the catalogue can't be reached the cards still render, just without a
price, and it's logged under the `customerCenter` scope.

Titles still fall back to the identifier. `/v1/products` returns no display name — the internal
API has `productName`, the public one doesn't — so "Pro Monthly" instead of "web_pro_monthly"
needs a field added to that payload. Pinned in a test so it's visible rather than folklore.

Adds `StoreProduct.init(catalogProduct:)`, which is the existing `testProduct:` initializer
under a name that doesn't imply test mode at this call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The catalogue gap-fill isn't scoped to web products, so a StoreKit failure now silently prices every App Store subscription from the dashboard catalogue, and the fetch it added sits on the Customer Center's first-paint path with 6 retries and no timeout. Details inline on CustomerCenterDependencies.swift.

Reviewed changes — the single commit since the prior pullfrog review at d75c8ce: 64f2464, which gives web (Stripe/Paddle) purchases a price.

  • Filled missing product prices from the Superwall catalogueLiveProductsProvider.products(for:) still asks StoreKit first, then computes the ids StoreKit didn't return and fetches /v1/products to fill them, mapping each through APIStoreProductStoreProductProductDisplayInfo. StoreKit results win; a failed fetch only logs and the cards render priceless.
  • Injected the DependencyContainer into LiveProductsProvider — the provider went from a stateless struct to one holding the container so it can reach network; .live passes it through.
  • Added StoreProduct(catalogProduct:) — a labelled convenience initializer over APIStoreProduct, identical in body to the existing init(testProduct:), documented as "a product from the Superwall catalogue rather than a store".
  • Added WebProductPricingTests — three cases decoding a Stripe /v1/products payload and asserting the resulting price, price line and title, plus the matching .pbxproj entries.
  • Added a CHANGELOG enhancement bullet recording that web cards now show a price and that titles still fall back to the product identifier.

ℹ️ What a web customer's card actually shows still isn't settled

The commit fixes the missing price, but the card a Stripe customer sees is titled with the raw product identifier (web_pro_monthly), formatted with US conventions regardless of their region, and — for a zero-decimal currency — possibly off by 100×. The CHANGELOG acknowledges the first of those; the other two are consequences of routing APIStoreProduct into a customer-facing surface for the first time, and each needs a decision rather than a patch.

Technical details
# Decide what a catalogue-priced card is allowed to show

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift:151``title: product?.title ?? sub.productId`, and `ProductDisplayInfo.init(_:)` sets `title` to
  `product.productIdentifier` when there is no `sk1Product`/`sk2Product`. Every catalogue-backed
  card therefore shows an internal identifier as its heading.
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift:171-181``storefrontLocale` passes `superwallProduct.storefront` to `Locale.Region`.
  `SuperwallProduct.storefront` is documented as e.g. `"USA"` (3-letter, matching Apple's
  `Storefront.countryCode`), while `Locale.Region` takes BCP-47 region subtags, which are
  2-letter. So `Locale.Region.isoRegions.contains(region)` never matches and the `en_US`
  fallback is taken for every product on every OS version. The currency code is still correct,
  so only grouping and symbol placement are wrong (`€1,234.56` rather than `1.234,56 €`).
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift:41-44``price`
  divides `amount` by 100 unconditionally. That is right for 2-decimal currencies and wrong for
  zero-decimal ones (JPY, KRW, VND) if the backend passes Stripe amounts through unnormalised.
  This is not verifiable from the client.

## Required outcome
- A decision on whether a raw product identifier is acceptable as a card title at release, or
  whether it should fall back to a localized generic label until `/v1/products` carries a name.
- Confirmation from the backend that `price.amount` is normalised to minor units for
  zero-decimal currencies, or a currency-aware divisor in `APIStoreProduct.price`.

## Open questions for the human
- Is `storefrontLocale`'s alpha-3 vs alpha-2 mismatch worth fixing now that catalogue prices
  reach paying customers, or is USD-only pricing a safe enough assumption for the moment?

ℹ️ Nitpicks

  • Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift:89-108missingPriceDegradesGracefully is named for the card still rendering but only asserts display.price == 0; no presentation is built, so nothing checks that the price line is omitted rather than shown as $0.00.
  • Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift:82display.localizedPrice ?? "!" is a dead fallback. StoreProduct.localizedPrice is non-optional and ProductDisplayInfo.init(_:) assigns it directly, so the nil branch is unreachable for any catalogue product.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift Outdated
let missing = ids.subtracting(resolved.keys)
guard !missing.isEmpty else { return resolved }
do {
let response = try await container.network.getSuperwallProducts()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This round trip is on the Customer Center's first-paint path: apply(customerInfo:refetchProducts:) only leaves .loading after it returns, and all four apply call sites pass refetchProducts: true, so a web-store customer pays it again on every restore and every sheet dismissal with nothing cached in between. The endpoint takes Endpoint's defaults — 6 retries, exponential backoff, no timeout — so a failing backend holds the spinner for ~65s of backoff plus up to seven 60s attempts, and Task.retrying's unstructured task means closing the screen doesn't cancel it.

Technical details
# Keep the catalogue fetch off first paint and bound it

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:128,139``products(for:)` is awaited inside `apply`, and `state` only moves off `.loading` at `:139`.
  `CustomerCenterView.swift:166` renders a bare `ProgressView()` for the whole of that window.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:101,110,269,309` —
  the publisher sink, `load()`, `performRestore()` and `sheetDidDismiss()` all pass
  `refetchProducts: true`, and two of them can overlap, so concurrent identical fetches are possible.
- `Sources/SuperwallKit/Network/Endpoint.swift:24-26,446-454``superwallProducts()` overrides
  none of `retryCount = 6` / `retryInterval` / `timeout`.
- `Sources/SuperwallKit/Misc/Extensions/Task+Retrying.swift:24,27-67` — with `timeout == nil` no
  cancellation task is added; the loop runs attempts `0..<6` plus a trailing unconditional one,
  sleeping `TaskRetryLogic.delay` = `5^(1 + attempt/6) + jitter` between them. The enclosing
  `Task(priority:)` is unstructured, so cancelling `CustomerCenterView`'s `.task` does nothing.

## Required outcome
- The purchase list renders without waiting on `/v1/products`; prices fill in when the response
  arrives, the way `refreshAppStoreVersion()` is deliberately sequenced after the first `apply`
  (`CustomerCenterViewModel.swift:111-113`).
- One presentation issues at most one catalogue fetch, and it is bounded.

## Suggested approach (optional)
- Memoise the response on the provider (or share the in-flight task) for the lifetime of the
  presentation, and give the endpoint a `timeout` and a smaller `retryCount` — this is a
  cosmetic enhancement, not config, so failing fast is the right trade.

Comment on lines +53 to +59
@Test("the card shows a price rather than a bare identifier", arguments: [199, 999, 7999])
func cardShowsPrice(amountInCents: Int) throws {
let product = try decodeProduct(amountInCents: amountInCents)
let storeProduct = StoreProduct(
catalogProduct: APIStoreProduct(superwallProduct: product, entitlements: [])
)
let display = ProductDisplayInfo(storeProduct)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These cases build the ProductDisplayInfo by hand and hand it straight to the builder, so they never reach LiveProductsProvider.products(for:) — delete the whole gap-fill and all three still pass. What they actually pin is that APIStoreProduct yields a usable price, which was already true before this commit. The new logic (which ids count as missing, StoreKit winning over the catalogue, the failure path) has no coverage, and LiveProductsProvider taking a concrete DependencyContainer leaves no seam to give it any.

Technical details
# Give the gap-fill a test that can fail

## Affected sites
- `Tests/SuperwallKitTests/CustomerCenter/Logic/WebProductPricingTests.swift:36-108` — all three
  cases construct `APIStoreProduct``StoreProduct(catalogProduct:)``ProductDisplayInfo`
  directly; none invokes the provider whose behaviour the commit changes.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift:113-145``LiveProductsProvider` reaches the network through `container.network`, a concrete
  `DependencyContainer`, so there is no injection point for a stubbed catalogue response.

## Required outcome
- A test that fails if the gap-fill is removed, and one that fails if a StoreKit-resolved product
  is overwritten by its catalogue entry.

## Suggested approach (optional)
- Narrow the dependency to what the provider actually uses — a small
  `CustomerCenterCatalogueProviding` (or a closure) supplied by `.live` from `container.network`.
  That also gives the failure path (`catch` → cards render priceless) a way to be exercised.
  This is the same shape as the untested `AppStoreVersionLookup` live path noted at `0b90c4d`.

`/v1/products` carries no display name, so a web subscription's card was headed
`web_pro_monthly`. Rather than wait on the payload, derive something readable from the
identifier: split on separators and camel case, drop a leading reverse-DNS component, and
capitalise — `web_pro_monthly` becomes "Web Pro Monthly", `com.acme.pro_monthly` becomes
"Acme Pro Monthly". Acronyms and years are left as written, and anything that tidies to nothing
falls back to the raw identifier rather than an empty row.

Applied in `ProductDisplayInfo` where the identifier was already the fallback, not in the live
products provider where it started out. That covers App Store products with an empty display name
too, and — more to the point — puts it somewhere a test can reach, which the first attempt didn't.

Explicitly a stopgap: the moment the payload carries a real name, the real name wins.

Currency needed no change. `APIStoreProduct` formats with `currencyCode ?? "USD"` and the endpoint
sends "usd", so web prices already render as dollars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues of substance — the formatter is correct and exhaustively tested. One consistency gap inline.

Reviewed changes — the single commit since the prior pullfrog review at 64f2464: 1ef95b4, which turns a nameless product's identifier into something a customer can read. This answers part (a) of the previous review's "what a web customer's card actually shows" section.

  • Added ProductTitleFormatter — a caseless enum with one static entry point. It splits on ./_/-, drops a leading reverse-DNS component only when more than two remain, breaks camelCase before an uppercase that follows a lowercase or a digit (so SWPro survives intact), capitalises the leading letter unless the word is already all-uppercase, and returns the raw identifier when nothing usable comes out.
  • Seeded ProductDisplayInfo's title from itCustomerCenterDependencies.swift:81 replaces the raw product.productIdentifier seed. Both override branches are untouched, so an App Store product with a real displayName/localizedTitle renders byte-identically to before; the tidied title is reached only by catalogue-backed products and by store products whose name is empty.
  • Added ProductTitleFormatterTests — an 11-row table plus a 3-row degenerate-input table, all exact equality.
  • Updated the two assertions that pinned the old behaviour (WebProductPricingTests.swift:85, CustomerCenterDependenciesTests.swift:54) and reworded the CHANGELOG bullet from "titles still fall back to the product identifier" to the derived form.

Three things I checked and found clean, so they need no attention. Every row of the new table matches the implementation when traced by hand, and both tables fail if the formatter is reverted. The title is cosmetic only — PurchasePresentation.title reaches PurchaseCardView.swift:19 and two .navigationTitles and nothing else, so no analytics payload, support-email body or delegate callback carries a derived name. And every uppercased() here is the locale-independent Unicode form, so there is no Turkish dotted-i hazard and case-less scripts pass through unchanged.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

…ting one

Replaces the identifier-tidying transform with a `name` field on `SuperwallProduct`, threaded
into `ProductDisplayInfo`. It's `nil` today — `/v1/products` doesn't return a name — so web
products show their identifier, and they start showing a real name the moment the payload
carries one, with no further change here.

The transform was wrong on both ends. Its input is usually composed: web2 forces identifiers
like `live:price_123:no-trial` for Stripe apps, which tidied into "Live Price 123 No Trial" — a
plausible-looking product name that is entirely fiction, and worse than an obviously
machine-generated string. And its output aimed at the wrong thing: the real Stripe name is the
Product name, shared across a product's monthly and annual prices, so it reads "Pro" rather than
"Pro Monthly". Google Play has the same shape.

Thanks to the reviewer who caught the composed-identifier case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues of substance — the revert is complete and the new field is wired correctly. One inline nitpick, plus a scope question the field raises.

Reviewed changes — the single commit since the prior pullfrog review at 1ef95b4: 83bd0e4, which reverses that commit's approach. Instead of inventing a title from the identifier, it adds a name field to the catalogue model and reads it when the backend starts sending one.

  • Reverted ProductTitleFormatter — the enum, its two test tables, both .pbxproj entries and the two flipped assertions are all gone, and ProductDisplayInfo's title seed is back to product.productIdentifier. The reasoning is recorded in the amended test comment: a composed identifier like live:price_123:no-trial would tidy into a plausible-looking name that is pure fiction, and a real Stripe name is per-product ("Pro"), not per-price ("Pro Monthly"). That's the right call, and it retires the 1ef95b4 inline thread, whose whole premise was the inconsistency between tidied titles and the ?? productId fallbacks.
  • Added SuperwallProduct.name — an optional public var on the /v1/products model, nil today because the endpoint doesn't return the field yet.
  • Threaded it through the Customer Center's gap-fillProductDisplayInfo.init(_:name:) takes an optional display name applied after the SK2/SK1 branches, and LiveProductsProvider.products(for:) passes product.name for each catalogue-filled id.
  • Added usesDisplayNameWhenPresent and restored the CHANGELOG bullet to "Titles still show the product identifier…".

Four things I checked and found clean, so they need no attention. SuperwallProduct has no hand-written CodingKeys, and its sibling SuperwallProductSubscription spells its snake_case keys out explicitly, so there is no global key strategy — name decodes from a literal "name" key and is absent-safe. The var-not-let choice does exactly what its comment claims: Swift gives an optional var stored property an implicit nil default, so the memberwise initializer defaults it and the seven SuperwallProduct(...) sites in CustomProductTests.swift compile untouched — a let would not have. The name parameter outranks the SK2/SK1 branches, but only CustomerCenterDependencies.swift:137 ever passes one and only for ids StoreKit didn't return, so App Store titles are byte-identical to before either commit. And the .pbxproj churn around the deletion is hunk-shift only — Paywall.swift, PKCS7.swift, ArchiveManifest.swift and the three test files that appear in the range-diff all still have their entries, and no dangling ProductTitleFormatter reference remains anywhere.

ℹ️ SuperwallProduct.name has exactly one reader, and it isn't the paywall

SWProduct.init(product: APIStoreProduct) hardcodes localizedTitle = product.superwallProduct.identifier — the same identifier-as-title substitution this commit set out to make unnecessary. SWProduct is what PaywallLogic sends to the paywall web view as product variables, and the other two APIStoreProduct construction sites build from the same /v1/products payload that now carries name. So on the day the backend ships the field, a web customer sees "Pro" in the Customer Center and web_pro_monthly on the paywall that sold it to them.

Technical details
# Decide whether the paywall path should read `SuperwallProduct.name` too

## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift:371``SWProduct.init(product:)` sets `localizedTitle = product.superwallProduct.identifier`
  unconditionally.
- `Sources/SuperwallKit/Paywall/Request/PaywallLogic.swift:114-137``storeProduct.swProduct`
  is collected into `swProducts` and handed to the paywall as product variables, so
  `localizedTitle` is customer-visible template data.
- `Sources/SuperwallKit/Config/ConfigManager.swift:710` (test-mode products) and
  `Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift:136` (custom products
  bought through an external `PurchaseController`) — both build `APIStoreProduct` from the same
  `/v1/products` response that now carries `name`.

## Required outcome
- A decision on whether `name` is a Customer-Center-only concern or a property of the catalogue
  product, so the two surfaces don't diverge the moment the backend ships the field.

## Suggested approach (optional)
- If it's the latter, `localizedTitle = product.superwallProduct.name ?? product.superwallProduct.identifier`
  at `APIStoreProduct.swift:371` covers every consumer, and the `name:` parameter on
  `ProductDisplayInfo.init` could then read through `StoreProduct` rather than being passed
  alongside it.

ℹ️ Nitpicks

  • Sources/SuperwallKit/Network/V2ProductsResponse.swift:28-29 — "A var rather than a let purely so the memberwise initializer defaults it to nil" is accurate and useful, but it's an internal rationale sitting in a public property's doc comment, so it ships in generated API docs. name is also the only mutable property on an otherwise all-let public struct; worth a // comment rather than ///.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +96 to +97
/// The field the backend hasn't shipped yet. Once `/v1/products` returns a name, it's used
/// with no further change on this side — this test is what proves that wiring works today.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test builds ProductDisplayInfo(storeProduct, name: product.name) by hand, so it never reaches the wiring — that lives at CustomerCenterDependencies.swift:137, inside LiveProductsProvider.products(for:), which no test invokes. Delete name: product.name from that call site and this still passes. What it genuinely pins is that name decodes and that the initializer honours it, which is worth having under a comment that says so.

Suggested change
/// The field the backend hasn't shipped yet. Once `/v1/products` returns a name, it's used
/// with no further change on this side — this test is what proves that wiring works today.
/// The field the backend hasn't shipped yet. Pins that `name` decodes off the payload and that
/// `ProductDisplayInfo` honours it; the `LiveProductsProvider` call site that passes it through
/// is still uncovered.

Five behaviour bugs from review.

The TestFlight guard didn't work on iOS 15. `ReceiptManager.isSandboxEnvironment` is only
assigned inside an `#available(iOS 16.0, *)` branch, so below that it stays nil, `?? false`, and
the guard never fires — the exact users it exists for would have been told to "update" to an
older App Store build. Now goes through `DeviceHelper`, which falls back to the simulator flag
and the receipt URL and accounts for test mode.

The catalogue fallback filled every identifier StoreKit didn't return, not just web ones — and
`products(for:)` swallows failures, so an offline StoreKit would have quoted the dashboard's
storefront price, formatted en_US, for App Store subscriptions. Restricted to non-iOS platforms.

A comped entitlement has no transaction behind it, so its store is nil and the builder reports
`.superwall`, which reads as a web store. That customer was shown "Manage subscription" and told
to find a link in a receipt they were never sent. Entitlement-only purchases now get the page
only when one exists, and never the receipt blurb.

The catalogue fetch sat on the path out of `.loading` with the endpoint's defaults — six retries,
exponential backoff, no timeout — so a failing backend could hold the spinner for minutes over
prices that are a nicety. Bounded to five seconds.

`customerCenterOpen` was tracked behind the App Store lookup, so closing the screen mid-request
could emit close before open. The lookup now runs after tracking, which also restores the
"render first, banner later" behaviour it was meant to have.

Splits the manage-subscription resolution into its own function; the added branch pushed the
resolver's switch past the complexity limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The comped-entitlement fix keys on PurchaseKind rather than on whether the entitlement has a store behind it, so it also removes the web management row from genuine Stripe/Paddle subscribers — the audience d75c8ce added it for — and the test written for it stays green with the new branch deleted. Details inline on CustomerCenterPathResolver.swift:87.

Reviewed changes — the single commit since the prior pullfrog review at 83bd0e4: d8a3470, which answers four of the open review threads.

  • Restricted the catalogue gap-fill to products StoreKit was never going to resolve — the fill loop now requires product.platform != .ios, so a failing StoreKit lookup no longer prices App Store subscriptions from the dashboard catalogue.
  • Bounded the catalogue fetch at five seconds — a new withCatalogueTimeout races container.network.getSuperwallProducts() against a Task.sleep in a throwing task group, so the endpoint's six retries can no longer hold the spinner for minutes.
  • Backed LiveEnvironment.isSandbox with DeviceHelpercontainer.deviceHelper.isSandbox == "true" replaces the raw ReceiptManager.isSandboxEnvironment ?? false, which resolved to "production" on iOS 15 forever; detectSandbox() covers the simulator, the TestFlight receipt URL and test mode.
  • Moved refreshAppStoreVersion() below the open-event track — the App Store lookup can no longer delay customerCenterOpen behind a 60s URLSession default.
  • Stopped telling an entitlement with no transaction to check an emailed receipt — the web .manageSubscription branch returns no row for an .entitlementOnly purchase when no management URL is configured, plus one new test.
  • Extracted manageSubscriptionDestination out of the destination(for:context:) switch — a pure move to stay under the complexity limit.

I compiled and ran the withCatalogueTimeout shape in isolation (Swift 6.3, Swift 5 language mode) rather than reasoning about it: the fast path returns the response and the cancelled Task.sleep's CancellationError is discarded by the group rather than surfacing, and the timeout path throws at exactly the deadline. So a successful fetch can't be turned into a logged failure, and the spinner is genuinely bounded. Note the abandoned Task.retrying chain keeps running unstructured in the background — that, and the fetch still sitting on first paint with nothing memoised, are the parts of the open CustomerCenterDependencies.swift:127 thread this commit doesn't close.

⚠️ Nothing in the repo shows a web purchase arriving as a transaction, and that decides whether the web branch is reachable at all

CustomerInfo.merging(with:) merges webCustomerInfo.subscriptions and SubscriptionTransaction.store decodes "STRIPE"/"PADDLE", so the client is built for web subscription transactions. But the only production constructors of either transaction type hardcode store: .appStore, and every fixture in the repo models a web purchase as an entitlement with subscriptions: []. Which shape /redeem and /entitlements actually return isn't visible from the client, and the answer decides both the inline finding above and whether WebSubscriptionPathTests' five .subscription-kind cases describe anything a customer can reach.

Technical details
# Establish which `CustomerInfo` shape a live web subscriber produces

## Affected sites
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:143,161` —
  the only production `SubscriptionTransaction(...)` / `NonSubscriptionTransaction(...)` calls,
  both `store: .appStore`.
- `Sources/SuperwallKit/Models/Customer Info/CustomerInfo.swift:117-126``merging(with webCustomerInfo:)` appends `webCustomerInfo.subscriptions`, so a backend that
  sends them would produce `.subscription`-kind presentations with a web store.
- `Tests/SuperwallKitTests/Models/Web2App/RedeemResponseTests.swift:44,65,85` and
  `Tests/SuperwallKitTests/Models/CustomerInfoDecodingTests.swift:243-275` — every Stripe fixture
  in the repo is `subscriptions: []` plus an active entitlement with `store: "STRIPE"`.
- `Tests/SuperwallKitTests/CustomerCenter/Logic/WebSubscriptionPathTests.swift:17-31``webSubscription()` hand-builds `SubscriptionTransaction(store: .stripe)`, a value no
  production code path constructs.

## Required outcome
- A recorded answer to "does a web purchase reach `CustomerInfo.subscriptions`, or only
  `CustomerInfo.entitlements`?", and the Customer Center's web path gated on whichever shape is
  real rather than on both by accident.

## Open questions for the human
- If web purchases are entitlement-only in practice, should `WebSubscriptionPathTests` be rebuilt
  around entitlements so the suite exercises the shape customers actually produce?

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift:100isWebStore is now dead: its only reader moved into manageSubscriptionDestination, which spells the [.stripe, .paddle, .superwall] set out again. The compiler warns (initialization of immutable value 'isWebStore' was never used), so it will show up in the next build log.
  • Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterDependencies.swift:165_ work: @escaping () async throws -> SuperwallProductsResponse is passed to group.addTask, which takes a sending closure, and SuperwallProductsResponse (V2ProductsResponse.swift:11) is the only type in that file without Sendable. Neither trips a diagnostic under the project's current settings, but both are errors under the Swift 6 language mode; the repo's analogous helper already writes operation: @Sendable @escaping (Task+Retrying.swift:21).

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// An entitlement with no transaction behind it — comped, or granted by hand — has a nil store
// that the builder reports as `.superwall`, which lands here. There is no subscription to
// manage, so offer the page only if one exists and never claim a receipt was sent.
if case .entitlementOnly = purchase.kind {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.entitlementOnly is not the same signal as "no transaction behind it". A web purchase arrives as a bare Entitlement whenever the backend sends no matching transaction — CustomerInfoDecodingTests.swift:243-275 decodes exactly that with store: "STRIPE" — and the only production constructors of SubscriptionTransaction/NonSubscriptionTransaction hardcode store: .appStore (EntitlementProcessor.swift:143,161), so a paying Stripe/Paddle subscriber may only ever be .entitlementOnly. Keying on kind therefore takes the .webManageUnavailable row back off the customers d75c8ce added it for; entitlement.store == nil is the comped signal you want, and the .entitlementOnly payload already carries it.

Technical details
# Discriminate the comped grant by its missing store, not by its purchase kind

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Logic/CustomerCenterPathResolver.swift:84-89` — the new
  branch's premise ("an entitlement with no transaction behind it … has a nil store") is true of
  comped grants but not exclusive to them.
- `Sources/SuperwallKit/CustomerCenter/Logic/PurchasePresentationBuilder.swift:41-43,182``entitlementOnly` presentations are built for every active entitlement whose `productIds` are
  disjoint from the known transaction ids, with `store: entitlement.store ?? .superwall`. A
  genuine `.stripe`/`.paddle` entitlement lands there with its real store intact.
- `Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift:143,161` —
  both transaction constructors are `store: .appStore`, so no production path produces a
  `.subscription`-kind presentation with a web store.
- `Sources/SuperwallKit/CustomerCenter/Models/PurchasePresentation.swift:28``case entitlementOnly(Entitlement)` already carries the entitlement, so its `store` is in hand.

## Required outcome
- A comped or hand-granted entitlement offers no management row and never claims a receipt was
  sent, while a real web-store entitlement keeps the `.webManageUnavailable` row it gained in
  `d75c8ce`.

## Suggested approach (optional)
- `if case .entitlementOnly(let entitlement) = purchase.kind, entitlement.store == nil`. Note
  `.superwall` is ambiguous — it is both the builder's nil fallback and a real store value — which
  is why the entitlement's own `store` is the cleaner discriminator.
Suggested change
if case .entitlementOnly = purchase.kind {
if case .entitlementOnly(let entitlement) = purchase.kind, entitlement.store == nil {

info: CustomerInfo(
subscriptions: [],
nonSubscriptions: [],
entitlements: [Entitlement(id: "pro")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case can't fail. Entitlement(id:)'s public convenience initializer sets store: .appStore (Entitlement.swift:214-222), so the presentation's store is .appStore and resolution never reaches the web branch at all — manage == nil comes from the App Store branch's guard let sub failing, and it stays green with the new .entitlementOnly branch deleted. Passing store: nil selects the internal memberwise initializer, giving the .superwall fallback the surrounding comment describes.

Suggested change
entitlements: [Entitlement(id: "pro")]
entitlements: [Entitlement(id: "pro", store: nil)]

Comment on lines +120 to +123
// Last, and deliberately so: this makes a network call, and everything above it — the first
// render and the open event — must not wait on it. Tracking open behind it would let a user
// who closes the screen mid-lookup emit close before open.
await refreshAppStoreVersion()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The move closes the App Store lookup's contribution, but the comment claims more than the code delivers: apply(...) above it awaits products(for:) — a StoreKit round trip plus, for a web account, the now-5s-bounded catalogue fetch — and dismiss() tracks customerCenterClose from an unstructured Task 0.6s after the surface disappears, so closing the screen while the spinner is up still emits close before open. hasAnyPurchases(info) is a pure function of info, so the open event could be tracked between fetchCustomerInfo() and apply(...) if the ordering is meant to be a guarantee rather than a best effort.

A pushed Customer Center used to hide the host's navigation bar, supply its own in place of it,
install a gesture delegate to keep swipe-back alive, and restore the lot on the way out. That was
the wrong trade: an SDK has no business mutating a host's chrome, and every one of those moving
parts was a glitch waiting for a host whose bar is styled or whose swipe-back is deliberately off.

The constraint that drove it was real — SwiftUI's `NavigationLink` needs a SwiftUI navigation
ancestor, and a `UINavigationController` isn't one, so dropping our own `NavigationView` killed
the drill-downs. The fix is to push them properly rather than to take over the bar:
`CustomerCenterNavigating` pushes purchase history and purchase detail onto the host's stack as
their own hosting controllers. Their bar, their back button, their appearance, untouched.

Each pushed destination is a fresh SwiftUI root, so the Customer Center's strings, theme and
navigator are reapplied to it, and it carries the sheet modifiers itself. Only the topmost
surface presents: every screen still in the stack applies those modifiers, so without a check
they would all race to present the same sheet. `pushDepth` on the view model and a matching
environment value settle which one wins — gating the bindings rather than the modifiers, so the
view tree stays stable.

Modal is unchanged: it still supplies its own navigation and close button, because there is no
host navigation to defer to.

Removes `showsBackButton`/`onBack` from `CustomerCenterNavigationOptions` and the
`customer_center_back` string from all 41 locales, none of which have a purpose now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new pushed navigation ships two defects that no test can see: the "only the topmost surface presents a sheet" gate is written but never applied to any binding, and a pushed drill-down that the host merely covers has no cover-veto, so it fires customerCenterDidDismiss() + customerCenter_close 0.6s later and latches. Details inline on CustomerCenterSheets.swift:29 and CustomerCenterPushNavigator.swift.

Reviewed changes — the single commit since the prior pullfrog review at d8a3470: 1b1d7b3, which rewrites .pushed to stop touching the host's navigation bar.

  • Dropped the host navigation-bar takeover entirelyviewWillAppear/viewWillDisappear and the private InteractivePopGestureDelegate are gone; .pushed now sets usesExistingNavigation: true and renders into the host's own bar. neitherStyleTouchesTheHostBar is parameterised over both styles and asserts the bar, the pop recognizer's delegate and its enablement are all untouched.
  • Added CustomerCenterPushNavigator and CustomerCenterDrillDown — with no SwiftUI navigation ancestor in .pushed, each drill-down becomes its own UIHostingController pushed onto the host's stack with the Customer Center's environment reapplied; CustomerCenterDrillDown picks that path when a navigator is in the environment and a NavigationLink otherwise.
  • Added CustomerCenterViewModel.pushDepth and a customerCenterSurfaceDepth environment key — intended to mark which pushed surface is topmost.
  • Removed showsBackButton/onBack from CustomerCenterNavigationOptions, the backButtonToolbarItem, and the customer_center_back key from all 41 locales. No dangling references remain anywhere.
  • Rewrote the pushed-style tests — the four bar/gesture tests were deleted and modalStyleLeavesTheBarAlone folded into the new parameterised case.
  • Updated the CHANGELOG bullet and the DocC section to describe the new behaviour.

Two demo-app changes rode along with it: HomeView.swift lost its navigation-bar appearance setup (inline below), and Superwall_Basic-Products.storekit was rewritten by a newer Xcode.

⚠️ The navigation mechanism the .pushed style now depends on has no test coverage

1b1d7b3 deletes 113 lines of pushed-style tests and adds none. Nothing exercises CustomerCenterPushNavigator.push — not the depth bookkeeping, not which branch CustomerCenterDrillDown takes, not that a drill-down reaches the host's stack at all. Both findings inline below live in exactly that gap, and the existing harness already builds real windows and UINavigationControllers, so this is reachable from the hostless target.

Technical details
# Cover the UIKit push navigation the `.pushed` style now relies on

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift:27-55``push(_:)` is unreferenced by any test.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift:60-82``CustomerCenterDrillDown`'s navigator-vs-`NavigationLink` branch is unexercised.
- `Tests/SuperwallKitTests/CustomerCenter/UIKit/CustomerCenterViewControllerTests.swift:190-226` — the chrome section lost four tests and gained one; no test replaces the coverage for what `.pushed` now actually does.

## Required outcome
- A test that pushes a `.pushed` controller onto a real `UINavigationController`, drives `CustomerCenterPushNavigator.push`, and asserts the host stack grew and `viewModel.pushDepth` moved to 1 — then pops and asserts it returned to 0.
- A test that covers a pushed drill-down and asserts no dismissal is delivered (this one fails today — see the `CustomerCenterPushNavigator.swift` thread).
- A test that pins whichever sheet-gating rule replaces the unwired `isTopmost` (see the `CustomerCenterSheets.swift` thread), so a second surface applying the same modifier cannot present.

## Open questions for the human
- Does the hostless target let a `UIHostingController`'s SwiftUI body evaluate far enough to reach a `CustomerCenterDrillDown` button action? If not, testing `CustomerCenterPushNavigator.push` directly against a real navigation controller still covers the depth arithmetic, which is where the fragility is.

ℹ️ Nitpicks

  • Examples/Basic/Basic/Superwall_Basic-Products.storekit — a newer Xcode rewrote the file: version.major 4 → 5, introductoryOfferintroductoryOffers, billingPlans added, "current": null stripped from every error entry. Unrelated to this commit's subject, and the format bump may stop the sample opening in an older Xcode.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift Outdated
Comment thread Examples/Basic/Basic/HomeView.swift Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift Outdated
Comment on lines +195 to +198
/// Taking over the host's bar is gated on the style, not on merely finding a navigation
/// controller: a `.modal` controller that happens to be inside one must leave it alone.
/// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on
/// the way out. It no longer touches the bar in any style — the host's chrome is theirs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first two sentences describe the behaviour this commit removed and now contradict the two below them.

Suggested change
/// Taking over the host's bar is gated on the style, not on merely finding a navigation
/// controller: a `.modal` controller that happens to be inside one must leave it alone.
/// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on
/// the way out. It no longer touches the bar in any style — the host's chrome is theirs.
/// The Customer Center used to hide a host's navigation bar in `.pushed` and hand it back on
/// the way out. It no longer touches the bar in any style — the host's chrome is theirs.

Self-review of the previous commit found the depth gate it introduced was never wired up.
`isTopmost` was declared and referenced nowhere, so every screen in a pushed stack still applied
ungated bindings and all of them raced to present the same sheet — the exact behaviour the
mechanism existed to prevent. Every binding now consults it.

The setters needed it too, not just the getters. A surface that isn't topmost could still clear
`viewModel.sheet` and fire `sheetDidDismiss()`, dismissing a sheet another screen owns and running
its deferred follow-up against the wrong screen.

Restoring the depth on pop was order-dependent. Popping several screens at once removes them all,
and UIKit doesn't document the order it calls `didMove(toParent:)` in; assigning `depth - 1` let a
deeper screen's callback overwrite a shallower one and strand the depth above the surface the user
had returned to, leaving it unable to present anything again. A first attempt at guarding on
"still topmost" fixed one ordering and not the other — the new test caught that — so it now takes
`min`, which gives the same answer whichever way round the callbacks arrive.

Pushed screens also captured the colour scheme at push time, so one opened in light mode kept the
light palette after a switch to dark while the root followed along. The theme is now resolved
inside the destination from the environment.

Drops `isShowingPushedSurface`, `pushedSurfaceCount` and the `isPushed:` parameters. Their only
consumer was the gesture delegate deleted in the previous commit, and leaving a second, unused
notion of navigation depth next to `pushDepth` invites the two to drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The topmost-surface gate is now wired into all three bindings, but CustomerCenterSheetsModifier is applied outside the .environment(\.customerCenterSurfaceDepth, depth) write that feeds it, so it reads the key's default 0 on every pushed screen. With pushDepth == 1 neither the root nor the drill-down evaluates isTopmost as true, and no sheet can present at all from a pushed screen in .pushed. Details inline on CustomerCenterPushNavigator.swift.

Reviewed changes — the single commit since the prior pullfrog review at 1b1d7b3: 6ba5ddf, which answers two of that review's five inline findings.

  • Applied isTopmost to the sheet bindingsisManagePresented, refundBinding, itemSheet and onItemSheetDismiss in CustomerCenterSheets.swift all consult it now, on both the getter and the setter side, so the modifier itself stays unconditional and only the view tree's values change.
  • Clamped the depth restore with minCustomerCenterPushedHostingController.onRemovedFromParent takes min(pushDepth, depth - 1) instead of assigning, so a popToRootViewController that removes several screens settles on the root's value whichever order UIKit reports them in.
  • Moved theme resolution into the pushed destination — a new private CustomerCenterThemedContainer recomputes CustomerCenterTheme from @Environment(\.colorScheme) rather than freezing presenter.traitCollection at push time, so a pushed screen follows a live light/dark change instead of stranding on whichever appearance was active when it opened.
  • Deleted the dead push bookkeepingpushedSurfaceCount, isShowingPushedSurface and the isPushed: parameter on surfaceDidAppear/surfaceDidDisappear are gone along with the three call sites that passed true; grep finds no remaining references.
  • Added CustomerCenterSheetOwnershipTests — three cases over pushDepth, plus the matching .pbxproj entries.

Three of the five threads from the last review are untouched and stay open: the missing cover-veto on CustomerCenterPushedHostingController, the Examples/Basic/Basic/HomeView.swift whitespace-only init, and the contradicting test doc comment (the suggested replacement was appended below the sentences it was meant to replace rather than substituted for them).

⚠️ .pushed keeps producing defects and still has no end-to-end coverage

This is the fourth consecutive review round on a presentation style that CustomerCenterManager never uses — every entry point the SDK itself drives passes .modal. 1b1d7b3 rewrote it, 6ba5ddf fixes two of that rewrite's findings and introduces the ordering bug above, and the one test file added covers arithmetic rather than the navigator. The rest of the Customer Center is in materially better shape than this corner of it, and nothing in the modal path depends on .pushed shipping in the same PR.

Technical details
# Decide whether `.pushed` ships with this PR or follows it

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift` — 92 lines, no test drives `push(_:)`.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterNavigator.swift:55-83``CustomerCenterDrillDown`'s navigator-vs-`NavigationLink` branch is unexercised.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift:20-81``CustomerCenterSheetsModifier` has no test at all; the gate it now applies is verified only by a reimplementation in the test file.
- `Sources/SuperwallKit/CustomerCenter/CustomerCenterManager.swift` — always passes `.modal`, so nothing in the SDK's own presentation path regresses if `.pushed` is deferred.

## Required outcome
- Either `.pushed` gets a test that builds a real `UINavigationController`, drives `CustomerCenterPushNavigator.push`, and asserts against the *production* bindings that only the pushed surface presents — or `CustomerCenterPresentationStyle.pushed` comes out of this PR and lands separately.

## Open questions for the human
- Is `.pushed` a launch requirement, or was it added mid-review in response to one integration question? If the latter, splitting it would let the modal Customer Center merge on its own record.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift Outdated
Comment thread Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterSheets.swift Outdated
/// Reproduces the bindings a surface at `depth` sees, which is what decides whether it presents.
@available(iOS 15.0, *)
private func presents(depth: Int, viewModel: CustomerCenterViewModel) -> Bool {
depth == viewModel.pushDepth

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presents(depth:viewModel:) re-implements the production rule instead of calling it, and restoringOutOfOrderDoesNotStrand re-implements the navigator's min(...) in its own loop at :69 — so all three cases assert arithmetic this file defines, and none of CustomerCenterSheetsModifier, CustomerCenterPushNavigator.push or CustomerCenterPushedHostingController is reached. They pass today while the gate they describe presents nothing at all (see the CustomerCenterPushNavigator.swift thread). Driving the real modifier's bindings, or at minimum pushing through a real UINavigationController and asserting pushDepth, would make them able to fail.

…g it

Follow-up review of the loading cover found three things.

The animation was applied to the whole content subtree, so the content's insertion joined the
same transaction and took SwiftUI's default opacity transition — it faded in while the cover
faded out, a cross-dissolve rather than the reveal that was wanted. The content now carries
`.transition(.identity)`, leaving the cover as the only thing that animates. The animation stays
on the container that owns the condition, since a modifier on the departing view isn't what
supplies the transaction its removal transition runs in.

The cover called `ignoresSafeArea`, which extended an opaque fill into the region behind the
host's translucent navigation bar and flattened it for the duration of the load — chrome the
pushed style promises not to touch. The overlay already fills the content area without it.

It also hardcoded the system grouped background. It now prefers a configured
`Appearance.background`, falling back to that colour. Nothing else reads `theme.background` yet,
so this changes nothing today, but it means the cover isn't a second place to remember when
background theming is wired up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues of substance — the reveal is a genuine improvement on the cross-dissolve and the state machine behind it holds. One half-wired theming detail inline.

Reviewed changes — the single commit since the prior pullfrog review at 6ba5ddf: 4e1d59d, which turns the load spinner from a branch of the state switch into a cover over it. One file, no behaviour outside CustomerCenterView.

  • Made the spinner a cover rather than a stand-incoreContent now renders loadedContent unconditionally and puts the spinner in an .overlay, so the management/no-purchases screen is revealed as the cover fades instead of being swapped in beside it. .loading renders Color.clear, since which of the two screens it becomes isn't known until the load finishes.
  • Stopped the content animating with the cover.transition(.identity) on loadedContent leaves the cover's .transition(.opacity) as the only thing the shared transaction animates, with .animation(_:value: viewModel.state) staying on the container that owns the condition.
  • Honoured reduce motion — a new @Environment(\.accessibilityReduceMotion) nils the 0.24s ease-out, matching what ManagementScreenView already does for the update banner.
  • Gave the cover an opaque fill — a configured Appearance.background falling back to systemGroupedBackground, so nothing shows through and no touch reaches a half-built screen while it's up.

Three things I checked and found clean, so they need no attention. state is @Published private(set) and assigned in exactly one place (CustomerCenterViewModel.swift:140), always to .management or .noPurchases — so the opaque cover shows once per presentation and can never reappear over live content, and RestoreOverlay, which sits under it in the ZStack, is only reachable after it's gone. Both loaded screens are .listStyle(.insetGrouped), so the default fallback colour is exactly what the cover reveals. And although recomputeUpdateBanner() sets showsUpdateBanner in the same synchronous run as the state assignment, ManagementScreenView is being built for the first time in that transaction, so the banner is part of the initial render rather than an insertion that could inherit the cover's animation.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift Outdated
DreamingInBinary and others added 2 commits August 28, 2026 13:32
…estore an unrelated example

Five things from review, four of them live bugs in the pushed presentation.

The sheet gate still didn't work. `.customerCenterSheets(...)` was applied outside the
`.environment(...)` writes, and a modifier resolves its own `@Environment` against the values
above it — so every pushed surface read the default depth of 0 and, once anything was pushed, no
surface owned the sheet at all: refund, manage, survey and change-plan all did nothing from a
purchase detail. The depth is now passed as an argument rather than through the environment,
which removes the ordering hazard rather than getting the order right this time.

Gating the sheet *setters* on that depth was my mistake and made things worse: the depth drops
when a screen is popped, with no regard for whether that screen has a sheet open, so a pop while
one was up vetoed its dismissal, left `viewModel.sheet` non-nil, skipped `sheetDidDismiss()`, and
let the root re-present the stale sheet unprompted. Only the getters are gated now, which is all
that's needed to stop two surfaces racing.

Pushed drill-downs had no dismissal veto. Covering one — a host push on top, a presentation, a
tab switch — let the visibility debounce fire and latch `didDismiss`, silencing the genuine
teardown. Same fix the root controller already had.

Depth was restored from `didMove(toParent:)` alone, which a pop doesn't reliably call: the new
tests caught that the depth never came back down, so after any drill-down the root could never
present again. Removal is reported from `viewDidDisappear` when the controller is actually going,
with `didMove` still covering the covered-then-popped case, latched between them.

The tests are rewritten to drive `CustomerCenterPushNavigator` and the controllers it pushes.
The previous versions restated the arithmetic locally and passed against a gate that presented
nothing — the same hollow-test mistake this suite exists to avoid. They now wait for a pushed
controller to actually appear, since UIKit reports no removal for one that never did.

Also restores `Examples/Basic/Basic/HomeView.swift`. Its navigation bar styling was deleted in a
working-tree change that predates this branch and got swept into 1b1d7b3 by a blanket `git add`.
It has nothing to do with the Customer Center.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confining it to the safe area left the spinner floating in a frame of unpainted screen — white
above and below the grey fill, most visible in the modal presentation.

The change that caused it was made on a review note that extending an opaque fill under the
host's translucent navigation bar would flatten it. That doesn't hold: the screen this becomes is
an inset-grouped `List`, which already runs under the bar and past the home indicator with the
same `systemGroupedBackground`. The region either side of the load is the same colour, so there
is nothing to flatten — only continuity to preserve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The new cover-veto on pushed drill-downs tests isMovingFromParent/isBeingDismissed on self only, while the sibling controller one file over deliberately walks the whole parent chain for exactly this reason. Dismissing a container that holds a .pushed Customer Center with a drill-down on top now vetoes the only dismissal signal, so customerCenterDidDismiss() and customerCenterClose never fire. Details inline on CustomerCenterPushNavigator.swift:92.

Reviewed changes — the single commit since the prior pullfrog review at 4e1d59d: 9e4ca76, which makes a pushed drill-down own its sheets. Four files.

  • Made the pushed destination's sheet modifier see its own depth.customerCenterSheets(...) is now applied to destination inside CustomerCenterThemedContainer, and the depth arrives as an explicit surfaceDepth: argument rather than through the environment. The customerCenterSurfaceDepth environment key and its EnvironmentKey are deleted outright, with no leftover readers. This is what makes the gate actually gate: previously the modifier sat above the writes and read the 0 default, so at pushDepth == 1 neither surface owned a sheet.
  • Extracted the rule into CustomerCenterSheetOwnership.isTopmost(surfaceDepth:pushDepth:) — a free function the modifier and the tests both call, so the tests can no longer restate arithmetic the production code owns.
  • Ungated the binding setters and onItemSheetDismiss — only the getters consult isTopmost now, so a screen popped while holding a sheet can still clear it and run sheetDidDismiss(). refundBinding keeps its own case .refund identity check; isManagePresented does not (inline).
  • Gave a pushed drill-down the root controller's cover-vetoCustomerCenterPushedHostingController overrides viewDidDisappear, reports removal when it is leaving and calls the new onCoveredWhileStillInStack otherwise, with a hasReportedRemoval latch since an ordinary pop is both a disappearance and a removal.
  • Rewrote CustomerCenterSheetOwnershipTests against the real navigator — three of the four tests now build a UIWindow + UINavigationController, drive CustomerCenterPushNavigator.push, and assert on pushDepth and a probe delegate's didDismiss count; the local re-implementations of the depth rule and the min(...) clamp are gone. coveringADrillDownDoesNotDismiss fails against the pre-9e4ca76 code.
  • Restored Examples/Basic/Basic/HomeView.swift's init — the navigation-bar title styling is back and the whitespace-only init is gone, leaving the Customer Center button as this PR's only change to that file.

I traced two things that came back clean, so they need no attention. Moving the sheets modifier inside CustomerCenterThemedContainer keeps strings and theme resolving correctly — .environment(\.customerCenterStrings, …) is applied to the container, whose body applies .customerCenterTheme to content, so both sit above the modifier, which is the ordering CustomerCenterView already used. And the hasReportedRemoval latch is safe under UIKit's pop sequence: viewDidDisappear (with isMovingFromParent still true) precedes didMove(toParent: nil), so the first call wins and the second is a no-op.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏


override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks self only, but CustomerCenterViewController.isLeavingHierarchy (:208-217) walks the whole parent chain, and its doc comment states why: "UIKit sets isBeingDismissed/isMovingFromParent only on the controller it is directly removing, so a Customer Center inside a container the host tears down — a navigation controller that gets presented and later dismissed, say — has to look up the chain too." So when a host presents a nav controller holding a .pushed Customer Center and the user is on a drill-down, dismissing it takes the else branch and cancels the debounce; the root controller underneath is covered, so it gets neither viewDidDisappear nor didMove(toParent:), and viewModel.dismiss() never runs. Before this commit the debounce delivered that case.

Technical details
# Make the pushed controller's teardown test match the root controller's

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift:92``isMovingFromParent || isBeingDismissed` on `self`, with no walk up `parent`.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterViewController.swift:208-217``isLeavingHierarchy`, the chain-walking version, plus its `wasPresentedModally && presentingViewController == nil` backstop.
- `Sources/SuperwallKit/CustomerCenter/UIKit/CustomerCenterPushNavigator.swift:100` — the comment claims "Same veto the root controller performs, one level down", which is the symmetry that does not currently hold.
- `Sources/SuperwallKit/CustomerCenter/ViewModel/CustomerCenterViewModel.swift:364-376``cancelPendingDismissal()` drops the debounce, and `dismiss()` is the only thing that fires `callbacks.didDismiss?()` and tracks `CustomerCenterClose`.

## Required outcome
- A `.pushed` Customer Center inside a container the host dismisses, with a drill-down on top, still delivers exactly one `customerCenterDidDismiss()` and one `customerCenterClose`.
- Covering a drill-down (host push, tab switch) still delivers neither.

## Suggested approach (optional)
- Lift `isLeavingHierarchy` out of `CustomerCenterViewController` into a shared `UIViewController` helper and have both controllers use it, so the two teardown tests cannot drift apart again.

## Open questions for the human
- Is a `.pushed` Customer Center inside a modally-presented navigation flow a shape you intend to support? The DocC section's settings-screen example is a pushed stack, which is commonly presented as a sheet.

guard isTopmost, case .manageSubscriptions = viewModel.sheet else { return false }
return true
},
set: { if !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the setters are ungated, this one clears viewModel.sheet whatever it currently holds, while refundBinding nine lines below checks case .refund first. A stale false write from a surface whose manage sheet is already gone would therefore tear down whichever sheet is up at the time and run sheetDidDismiss() against it — including its refreshReceipts() branch. Mirroring the sibling's identity check costs nothing and closes the asymmetry.

Suggested change
set: { if !$0 { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } }
set: { if !$0, case .manageSubscriptions = viewModel.sheet { viewModel.sheet = nil; Task { await viewModel.sheetDidDismiss() } } }

…ng cover

The cover was the only reader of `theme.background` in the SDK — `Appearance.background` is
computed and consumed nowhere else. Honouring it there alone meant a host that configured a
background got a tinted cover that then faded to reveal an untinted inset-grouped list: a colour
flip on exactly the screens that had asked for a background, and one that didn't exist before the
cover did.

Back to the grouped-list colour. `Appearance.background` belongs here when it's wired through the
screens themselves, not before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Nothing structural in this delta. One example-app edit looks like a leftover — suggestion inline.

Reviewed changes — the single commit since the prior pullfrog review at 9e4ca76: 17f81ff, three files and roughly fifteen lines.

  • Sent the loading cover edge to edge.ignoresSafeArea() on loadingCover, reversing 4e1d59d's deliberate omission, with the "would flatten the host's translucent bar" comment replaced by a continuity argument.
  • Attached a cancellation survey to the Advanced example's configmanage_subscription now carries a cancel_survey with three nil-titled options, so the demo exercises the survey → Apple cancel-sheet handoff.
  • Made the Advanced example configure with a SuperwallOptionstestModeBehavior = .never, inserted under the "Option 1" comment (inline).

I checked the example's survey against the SDK it mirrors and it holds up: the FeedbackSurvey is byte-identical to the one CustomerCenterConfiguration.default builds (:55-63), and customer_center_survey_too_expensive, ..._dont_use and ..._bought_by_mistake are each present in all 41 .lproj files — so the comment's claim that nil titles fall back to shipped localized strings is accurate in every locale.

ℹ️ Nitpicks

  • Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift:196-197 — "the region it covers ends up the same colour either way" holds only while Appearance.background is nil, where both the cover and the insetGrouped list resolve to systemGroupedBackground. With a background configured the cover now paints that colour under the host's translucent navigation bar and past the home indicator before fading to a list that doesn't carry it — which is the widened form of the open thread on :191, and the case the comment this replaced was written about.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread Examples/Advanced/Advanced/SuperwallAdvancedApp.swift Outdated

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No new issues — three clean reverts, and the two threads they answer are resolved. One scope question in the body; not approving only because earlier threads are still open.

Reviewed changes — the single commit since the prior pullfrog review at 17f81ff: 058dacf, two files, all reverts.

  • Stopped the loading cover reading theme.background — the fill is now an unconditional Color(uiColor: .systemGroupedBackground), with a comment recording that honouring a configured background here alone would tint the cover and then fade to an untinted list. This also settles 17f81ff's .ignoresSafeArea(): both sides of the fade are now the same colour in every configuration, so the edge-to-edge cover genuinely flattens nothing of the host's chrome.
  • Reverted the Advanced example's testModeBehavior = .neverSuperwall.configure(apiKey: apiKey) is the one-liner "Option 1" advertises again.
  • Reverted the Advanced example's cancellation surveymanage_subscription is back to .init(id:type:).

ℹ️ Four of the five public Appearance colours still do nothing

058dacf's comment states the position for background honestly, and that position is right for a cover in isolation. What it surfaces is the wider shape: CustomerCenterTheme (CustomerCenterEnvironment.swift:11-28) decodes all five slots, but only accent has a reader anywhere in Sources/. background, text, buttonText and buttonBackground are parsed from the host's hex strings, round-tripped through Codable, compared in isEqual, hashed — and never applied to a pixel. They are public, @objc-exposed, and documented in the DocC article's configuration surface, so a host who sets them gets no error and no effect.

Technical details
# Decide whether the unwired `Appearance` colours ship as public API

## Affected sites
- `Sources/SuperwallKit/CustomerCenter/Models/CustomerCenterConfiguration+Appearance.swift:17-21``accent`, `background`, `text`, `buttonText`, `buttonBackground`, all `public var ColorPair?` on an `@objc(SWKCustomerCenterAppearance)` class.
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterEnvironment.swift:22-27` — all five are resolved to `Color?` and stored on `CustomerCenterTheme`.
- Readers: `theme.accent` at `CustomerCenterView.swift:219` and `CustomerCenterPushNavigator.swift:78`. There are no readers of the other four (grep-verified across `Sources/`).
- `Sources/SuperwallKit/CustomerCenter/Views/CustomerCenterView.swift:192-195` — the new comment scoping `background` to "when background theming is wired up across the screens".

## Required outcome
- A recorded decision for the 4.17.0 public surface: either the four unwired colours are applied, or they come out of `Appearance` until they are. Shipping them means either honouring them later (fine) or removing them later (a breaking change to a public, `Codable`, ObjC-exposed type).

## Open questions for the human
- Is full appearance theming a fast follow, or is `accent` the intended scope for launch? If the latter, a documented "currently only `accent` is applied" note on `Appearance` would at least stop a host wiring up colours that never appear.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

…er test's veto

Puts back two things a blanket `git add` swept into 058dacf from the working tree: the Advanced
example's cancellation survey on the manage path, and its `testModeBehavior = .never`. Both are
part of this PR's demo of the feature; neither had anything to do with that commit's subject.

Also answers two review threads on the controller tests.

`pushedCoverDoesNotFireLateDismissal` called `viewDidDisappear` after the run loop had settled, so
the veto always ran last — the one ordering production doesn't have to guarantee — and nothing
established that SwiftUI's `onDisappear` had armed anything in this harness. The debounce is now
armed explicitly before the veto, so the test fails if the veto stops cancelling. Production
ordering is sound for a different reason, noted there: `viewDidDisappear` calls `super` first,
which is what forwards the disappearance into SwiftUI.

And the bar test's doc comment still opened with the behaviour that change removed, contradicting
the two sentences under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Aug 28, 2026

Copy link
Copy Markdown

Pullfrog billing is temporarily unavailable.

model-credential service temporarily unavailable — retry shortly

Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console.

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

…s something

`Appearance` published five colour slots and only `accent` ever reached a pixel. The other four
were parsed from the host's hex strings, round-tripped through `Codable`, compared and hashed, and
applied nowhere — so a host could set a background or text colour, get no error, and see no
change. Shipping them in 4.17.0 would have meant honouring them later or removing them later, and
removing a property from a public, `Codable`, Objective-C-exposed type is a breaking change.
They'll come back when they're wired up.

Also takes two test-only edits back out of the Advanced example: `testModeBehavior = .never`, and
the cancellation survey on the manage path. Neither was meant to ship — they were left over from
exercising the feature by hand, and I put them back in the previous commit while undoing an
unrelated blanket-add. The example's only change from this PR is now the Customer Center button
and its delegate; `SuperwallAdvancedApp.swift` is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Aug 28, 2026

Copy link
Copy Markdown

Pullfrog billing is temporarily unavailable.

model-credential service temporarily unavailable — retry shortly

Usually transient; the next dispatch should succeed. If it persists, check status.pullfrog.com or your console.

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants