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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- `OnboardingPaywallDecision.decide(offering:status:)` (`Subscription`), which decides whether
onboarding ends on the paywall: `.skip(.entitled)` for an entitled customer,
`.skip(.switchedOff)` when the offering's metadata sets `onboarding_paywall` to `off`, and
`.skip(.nothingToSell)` when there is no offering or it has no packages. Any other value, a
non-string value or no entry shows it. `placement`, `source` and `metadataKey` name the
`onboarding` placement, the `onboarding` source and the metadata entry.
- `OnboardingPaywallStep` (`SubscriptionUI`), which wraps the app's own paywall as the last step
of onboarding. It always draws a × in the top-leading corner and "Continue for free" beneath the
paywall, has no parameter to move or remove either, cannot be swiped away as a sheet, and ends
closing, continuing, buying and restoring in one `onFinish(OnboardingPaywallExit)`, once. The
paywall reports a purchase or a restore through `OnboardingPaywallContext`, which also carries
`source` and the presentation's `pw_id`.
- `OnboardingPaywallEvent`, the `paywall_shown` and `paywall_closed` the step reports through
`onEvent`, named as in the shared event catalog (`source=onboarding`, `pw_id`, `result`, `via`
as `close_button` / `continue_free` / `auto`, `product`, `open_s`). The package still depends on
no analytics SDK. `OnboardingPaywallLabels` holds the two strings, with `.japanese`.

Nothing existing changes.

## [3.6.1] - 2026-11-01

### Changed
Expand Down
38 changes: 38 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,44 @@ ID でなく offering そのもので買い、表示を記録すると、placeme
文字列の値だけを持つ。数・真偽値・オブジェクトの鍵は `droppedMetadataKeys` に入るので、読めない設定を
黙って捨てずに知らせられる。

### オンボーディングの最後にペイウォールを出す

どのアプリもオンボーディングの最後に、閉じられるペイウォールを出す。出すかはオンボーディングを始める前に
1 回だけ決める。`OnboardingPaywallDecision` は、購読中の人・offering の metadata の
`onboarding_paywall` が `off` のとき・価格が無いときに出さない:

```swift
let offering = try? await subscriptionUseCase.loadOffering(placement: OnboardingPaywallDecision.placement)
let status = await subscriptionUseCase.getSubscriptionStatus()
let decision = OnboardingPaywallDecision.decide(offering: offering, status: status)
// .show か .skip(.entitled / .switchedOff / .nothingToSell)
```

`off` 以外の値や値が無いときは出す。ダッシュボードの書き損じで消えない。出すときは、アプリのペイウォールを
`OnboardingPaywallStep`(`SubscriptionUI`)で包む。左上の×とペイウォールの下の「無料のまま続ける」は
いつも型が出し、閉じた・無料のまま続けた・買った・復元したを 1 つの `onFinish` にまとめる:

```swift
OnboardingPaywallStep(
labels: .japanese,
onEvent: { event in analytics.send(event.name, event.parameters) },
onFinish: { exit in router.showHome(askForNotifications: exit.isEntitled) }
) { context in
MyPaywall(
offering: offering,
paywallId: context.paywallId, // ペイウォール自身が送る paywall_* に付ける pw_id
onWorking: context.setWorking, // 購入の間は×と「無料のまま続ける」を押せなくする
onPurchased: context.purchased, // 買った SubscriptionPackage を渡す
onRestored: context.restored
)
}
```

`onEvent` には `paywall_shown` と `paywall_closed` が届く。どちらも `source=onboarding` と同じ
`pw_id` を持ち、閉じたときは `result`・`via`(`close_button`・`continue_free`、買った・復元したときは
`auto`)・`product`・`open_s` も持つ。名前は共通のイベントのカタログに合わせてある。計測の SDK には
依存しない。×の位置と「無料のまま続ける」を外す引数は無い。

## ドキュメント

**[API ドキュメントと Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)**
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,46 @@ the placement to RevenueCat. `metadata` keeps only string values; the keys of nu
objects are in `droppedMetadataKeys`, so a dashboard entry the app cannot read can be reported
rather than silently ignored.

### Ending onboarding on a paywall

Every app ends its onboarding on a paywall the customer can close. Decide once, before onboarding
starts, whether it appears; `OnboardingPaywallDecision` skips it for someone already entitled, when
the offering's metadata sets `onboarding_paywall` to `off`, and when there is no price to show:

```swift
let offering = try? await subscriptionUseCase.loadOffering(placement: OnboardingPaywallDecision.placement)
let status = await subscriptionUseCase.getSubscriptionStatus()
let decision = OnboardingPaywallDecision.decide(offering: offering, status: status)
// .show, or .skip(.entitled / .switchedOff / .nothingToSell)
```

Any value other than `off`, or no entry, leaves the paywall on, so a typo in the dashboard cannot
take it away. When it is shown, wrap the app's own paywall in `OnboardingPaywallStep`
(`SubscriptionUI`). The step draws the × in the top-leading corner and "Continue for free" beneath
the paywall, always, and ends closing, continuing, buying and restoring in one `onFinish`:

```swift
OnboardingPaywallStep(
labels: .japanese,
onEvent: { event in analytics.send(event.name, event.parameters) },
onFinish: { exit in router.showHome(askForNotifications: exit.isEntitled) }
) { context in
MyPaywall(
offering: offering,
paywallId: context.paywallId, // the pw_id for the paywall's own paywall_* events
onWorking: context.setWorking, // disables the ways out while a purchase is in flight
onPurchased: context.purchased, // with the SubscriptionPackage that was bought
onRestored: context.restored
)
}
```

`onEvent` receives `paywall_shown` and `paywall_closed` with `source=onboarding`, one `pw_id`,
and, on closing, `result`, `via` (`close_button`, `continue_free`, or `auto` after a purchase or a
restore), `product` and `open_s`, named as in the shared event catalog. The package depends on no
analytics SDK. The step has no parameter for the position of the × or for leaving out "Continue for
free".

## Documentation

**[API documentation and Getting Started](https://no-problem-dev.github.io/swift-subscription/documentation/subscription/)**
Expand Down
5 changes: 5 additions & 0 deletions Sources/Subscription/Documentation.docc/Subscription.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ sandbox account.
- ``SubscriptionUseCase/trackPaywallImpression(source:offeringId:)``
- ``SubscriptionUseCase/trackPaywallImpression(source:offering:)``

### Ending onboarding on a paywall

- ``OnboardingPaywallDecision``
- ``OnboardingPaywallSkipReason``

### SwiftUI integration

- ``SubscriptionUseCaseModifier``
Expand Down
101 changes: 101 additions & 0 deletions Sources/Subscription/Public/Model/OnboardingPaywallDecision.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

/// Whether the paywall at the end of onboarding is shown, and why not when it is not.
///
/// Every app ends its onboarding on a paywall the customer can close, and the same three things
/// keep it off the screen: the customer is already entitled, the dashboard switched it off, or
/// there is nothing to sell. ``decide(offering:status:)`` is that rule, written once.
///
/// ```swift
/// let offering = try? await subscriptionUseCase.loadOffering(placement: OnboardingPaywallDecision.placement)
/// let status = await subscriptionUseCase.getSubscriptionStatus()
/// switch OnboardingPaywallDecision.decide(offering: offering, status: status) {
/// case .show:
/// steps.append(.paywall)
/// case .skip:
/// break // go straight to the screen onboarding promised
/// }
/// ```
///
/// The switch is the offering's metadata entry ``metadataKey`` (`onboarding_paywall`). Set it to
/// `"off"` on the offering served at the ``placement`` to stop the paywall without an app
/// release, or on one side of an Experiment to compare onboarding with and without it. Any other
/// value, or no entry, leaves it on: showing the paywall is the default for every app, and a typo
/// in the dashboard must not quietly take it away. A non-string value (a JSON `false`) is not
/// read, because ``SubscriptionOffering/metadata`` keeps strings only; it too leaves the paywall
/// on, and its key is in ``SubscriptionOffering/droppedMetadataKeys``.
///
/// Decide once per install, when onboarding starts, and keep the answer for the rest of it, so a
/// dashboard change half-way through does not mix the two sides of the funnel.
public enum OnboardingPaywallDecision: Sendable, Hashable {
/// End onboarding on the paywall.
case show

/// Leave the paywall out and go straight to the screen onboarding promised.
case skip(OnboardingPaywallSkipReason)

/// The placement to load the onboarding paywall's offering from, and to name in the dashboard.
public static let placement = "onboarding"

/// The `source` of every paywall event at the end of onboarding, and the `source` to pass to
/// ``SubscriptionUseCase/trackPaywallImpression(source:offering:)``.
public static let source = "onboarding"

/// The offering metadata entry that switches the paywall off when its value is ``switchedOffValue``.
public static let metadataKey = "onboarding_paywall"

/// The metadata value that switches the paywall off, compared without regard to case or to
/// surrounding whitespace.
public static let switchedOffValue = "off"

/// Whether the paywall is shown.
public var shows: Bool {
self == .show
}

/// Decides whether onboarding ends on the paywall.
///
/// The reasons are checked in this order, and the first that applies is the one reported:
///
/// | | reported as |
/// |---|---|
/// | `status.isActive` | ``OnboardingPaywallSkipReason/entitled`` |
/// | no offering | ``OnboardingPaywallSkipReason/nothingToSell`` |
/// | ``metadataKey`` is ``switchedOffValue`` | ``OnboardingPaywallSkipReason/switchedOff`` |
/// | the offering has no packages | ``OnboardingPaywallSkipReason/nothingToSell`` |
///
/// A status that has not been verified yet and is not active counts as not entitled. That is
/// what a first launch holds, and it is exactly the customer the paywall is for.
///
/// - Parameters:
/// - offering: The offering the paywall would sell, normally the one served at
/// ``placement``. `nil` when it could not be loaded: a paywall without prices is not shown.
/// - status: The customer's entitlement as the app knows it now.
/// - Returns: Whether to show the paywall.
public static func decide(offering: SubscriptionOffering?, status: SubscriptionStatus) -> OnboardingPaywallDecision {
if status.isActive { return .skip(.entitled) }
guard let offering else { return .skip(.nothingToSell) }
if isSwitchedOff(offering.metadata[metadataKey]) { return .skip(.switchedOff) }
if offering.packages.isEmpty { return .skip(.nothingToSell) }
return .show
}

static func isSwitchedOff(_ value: String?) -> Bool {
guard let value else { return false }
return value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == switchedOffValue
}
}

/// Why the paywall at the end of onboarding was left out.
///
/// The raw values are stable identifiers for logging and analytics.
public enum OnboardingPaywallSkipReason: String, Sendable, Hashable, CaseIterable {
/// The customer is already entitled. Selling them what they have is not a paywall, it is noise.
case entitled

/// The offering's metadata switched the paywall off.
case switchedOff = "switched_off"

/// No offering, or one with no packages: there is no price to show, so there is nothing to sell.
case nothingToSell = "nothing_to_sell"
}
19 changes: 19 additions & 0 deletions Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ showing the RevenueCat paywall, and the container that switches between the two,
`SubscriptionRevenueCatUI` product. That container hands the app's paywall a
``PaywallHandlers`` so both report in the same three callbacks.

### The paywall at the end of onboarding

Every app ends its onboarding on a paywall the customer can close. Whether it appears is
`OnboardingPaywallDecision`'s call, in `Subscription`: not for someone already entitled, not when
the offering's metadata sets `onboarding_paywall` to `off`, and not when there is no price to show.
When it does appear, ``OnboardingPaywallStep`` wraps the app's own paywall in the parts that must
not differ between apps: a × in the top-leading corner from the first frame, "Continue for free"
beneath the paywall, one `onFinish` for closing, continuing, buying and restoring alike, and
`source=onboarding` on the `paywall_shown` and `paywall_closed` it reports. None of them has a
parameter to turn it off or move it.

## Topics

### The paywall
Expand All @@ -107,6 +118,14 @@ showing the RevenueCat paywall, and the container that switches between the two,
- ``PaywallChoice``
- ``PaywallHandlers``

### The paywall at the end of onboarding

- ``OnboardingPaywallStep``
- ``OnboardingPaywallContext``
- ``OnboardingPaywallExit``
- ``OnboardingPaywallEvent``
- ``OnboardingPaywallLabels``

### Previews

- ``PreviewSubscriptionUseCase``
62 changes: 62 additions & 0 deletions Sources/SubscriptionUI/Onboarding/OnboardingPaywallContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation
import Subscription

/// What ``OnboardingPaywallStep`` hands to the app's paywall: where it was opened from, the
/// identity of this presentation, and the way to report a purchase or a restore.
///
/// The paywall draws itself and buys through the store as it always does. When a purchase or a
/// restore leaves the customer entitled, it calls ``purchased(_:)`` or ``restored()``, and the step
/// takes it from there: one `paywall_closed`, then the single exit to the rest of the app. The
/// paywall does not dismiss itself and does not draw a close button.
public struct OnboardingPaywallContext {
/// Where the paywall was opened from. Always `onboarding` (`OnboardingPaywallDecision/source`):
/// pass it to `trackPaywallImpression(source:offering:)` and put it on the paywall's own events.
public let source: String

/// The `pw_id` of this presentation: put it on every `paywall_*` event the paywall sends itself,
/// such as `paywall_plan_selected` and `paywall_purchase_tapped`, so they join the step's
/// `paywall_shown` and `paywall_closed`.
public let paywallId: String

private let finish: @MainActor (OnboardingPaywallExit) -> Void
private let working: @MainActor (Bool) -> Void

init(
paywallId: String,
finish: @escaping @MainActor (OnboardingPaywallExit) -> Void,
working: @escaping @MainActor (Bool) -> Void
) {
self.source = OnboardingPaywallDecision.source
self.paywallId = paywallId
self.finish = finish
self.working = working
}

/// Reports a purchase that left the customer entitled. Onboarding moves on.
///
/// Call it only when the returned `SubscriptionStatus` is active: a cancelled purchase keeps the
/// customer on the paywall, and a pending one (Ask to Buy) is not a purchase yet.
///
/// - Parameter package: The plan that was bought.
@MainActor
public func purchased(_ package: SubscriptionPackage) {
finish(.purchased(package.duration))
}

/// Reports a restore that left the customer entitled. Onboarding moves on.
///
/// A restore that finds nothing is not an exit: say so on the paywall and keep it open.
@MainActor
public func restored() {
finish(.restored)
}

/// Says whether a purchase or a restore is in flight.
///
/// While it is, the × and "Continue for free" are disabled, so closing cannot race a purchase
/// that is about to succeed. Set it back to `false` whatever the outcome.
@MainActor
public func setWorking(_ isWorking: Bool) {
working(isWorking)
}
}
Loading
Loading