From 06d03e16e4e7e35ce0e20495571b66ebfbe40a14 Mon Sep 17 00:00:00 2001 From: Kyoichi Taniguchi Date: Sun, 8 Nov 2026 10:56:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=AA=E3=83=B3=E3=83=9C=E3=83=BC?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=B3=E3=82=B0=E3=81=AE=E6=9C=80=E5=BE=8C?= =?UTF-8?q?=E3=81=AE=E3=83=9A=E3=82=A4=E3=82=A6=E3=82=A9=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E5=88=A4=E5=AE=9A=E3=81=A8=E6=AE=B5=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Subscription に OnboardingPaywallDecision(購読中・metadata の onboarding_paywall=off・売る物が無いときは出さない純関数) - SubscriptionUI に OnboardingPaywallStep(左上の×と「無料のまま続ける」を必ず出し、閉じた・続けた・買った・復元したを1つの onFinish に) - OnboardingPaywallEvent で paywall_shown / paywall_closed(source=onboarding・pw_id・result・via・product・open_s)を共通のカタログの名前で渡す。計測の SDK には依存しない - テスト・README(en/ja)・DocC・CHANGELOG(Unreleased・足すだけ) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nvz9wH6uYZH4w3Met47ZdC --- CHANGELOG.md | 20 +++ README.ja.md | 38 +++++ README.md | 40 +++++ .../Documentation.docc/Subscription.md | 5 + .../Model/OnboardingPaywallDecision.swift | 101 ++++++++++++ .../Documentation.docc/SubscriptionUI.md | 19 +++ .../Onboarding/OnboardingPaywallContext.swift | 62 +++++++ .../Onboarding/OnboardingPaywallEvent.swift | 72 +++++++++ .../Onboarding/OnboardingPaywallExit.swift | 62 +++++++ .../Onboarding/OnboardingPaywallLabels.swift | 29 ++++ .../Onboarding/OnboardingPaywallStep.swift | 152 ++++++++++++++++++ .../OnboardingPaywallDecisionTests.swift | 97 +++++++++++ .../OnboardingPaywallEventTests.swift | 91 +++++++++++ 13 files changed, 788 insertions(+) create mode 100644 Sources/Subscription/Public/Model/OnboardingPaywallDecision.swift create mode 100644 Sources/SubscriptionUI/Onboarding/OnboardingPaywallContext.swift create mode 100644 Sources/SubscriptionUI/Onboarding/OnboardingPaywallEvent.swift create mode 100644 Sources/SubscriptionUI/Onboarding/OnboardingPaywallExit.swift create mode 100644 Sources/SubscriptionUI/Onboarding/OnboardingPaywallLabels.swift create mode 100644 Sources/SubscriptionUI/Onboarding/OnboardingPaywallStep.swift create mode 100644 Tests/SubscriptionTests/OnboardingPaywallDecisionTests.swift create mode 100644 Tests/SubscriptionUITests/OnboardingPaywallEventTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index efe42dc..398934e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.ja.md b/README.ja.md index c82233f..6005e15 100644 --- a/README.ja.md +++ b/README.ja.md @@ -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/)** diff --git a/README.md b/README.md index 7982c91..ea5c9af 100644 --- a/README.md +++ b/README.md @@ -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/)** diff --git a/Sources/Subscription/Documentation.docc/Subscription.md b/Sources/Subscription/Documentation.docc/Subscription.md index b5e234e..6c7e8cd 100644 --- a/Sources/Subscription/Documentation.docc/Subscription.md +++ b/Sources/Subscription/Documentation.docc/Subscription.md @@ -84,6 +84,11 @@ sandbox account. - ``SubscriptionUseCase/trackPaywallImpression(source:offeringId:)`` - ``SubscriptionUseCase/trackPaywallImpression(source:offering:)`` +### Ending onboarding on a paywall + +- ``OnboardingPaywallDecision`` +- ``OnboardingPaywallSkipReason`` + ### SwiftUI integration - ``SubscriptionUseCaseModifier`` diff --git a/Sources/Subscription/Public/Model/OnboardingPaywallDecision.swift b/Sources/Subscription/Public/Model/OnboardingPaywallDecision.swift new file mode 100644 index 0000000..e78b8ba --- /dev/null +++ b/Sources/Subscription/Public/Model/OnboardingPaywallDecision.swift @@ -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" +} diff --git a/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md b/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md index a0ab2a0..16cbe62 100644 --- a/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md +++ b/Sources/SubscriptionUI/Documentation.docc/SubscriptionUI.md @@ -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 @@ -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`` diff --git a/Sources/SubscriptionUI/Onboarding/OnboardingPaywallContext.swift b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallContext.swift new file mode 100644 index 0000000..615d376 --- /dev/null +++ b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallContext.swift @@ -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) + } +} diff --git a/Sources/SubscriptionUI/Onboarding/OnboardingPaywallEvent.swift b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallEvent.swift new file mode 100644 index 0000000..3a07acc --- /dev/null +++ b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallEvent.swift @@ -0,0 +1,72 @@ +import Foundation +import Subscription + +/// An analytics event from the paywall at the end of onboarding, named and shaped as the shared +/// event catalog names and shapes it. +/// +/// This package depends on no analytics SDK. ``OnboardingPaywallStep`` hands each event to a +/// closure, and the app sends it with whatever it sends events with: switch over the case for +/// typed values, or forward ``name`` and ``parameters`` as they are. +/// +/// ```swift +/// OnboardingPaywallStep(onEvent: { event in +/// analytics.send(event.name, event.parameters) +/// }, onFinish: { _ in showHome() }) { context in … } +/// ``` +/// +/// Both events carry `source=onboarding` and the same `pw_id`, which ties a close to the +/// impression it ends. The app adds what only it knows, such as `nth`, before sending. +public enum OnboardingPaywallEvent: Sendable, Hashable { + /// `paywall_shown`: the paywall appeared. + case shown(paywallId: String) + + /// `paywall_closed`: the customer left it, after `openSeconds` whole seconds on screen. + case closed(paywallId: String, exit: OnboardingPaywallExit, openSeconds: Int) + + /// The event's name in the catalog. + public var name: String { + switch self { + case .shown: "paywall_shown" + case .closed: "paywall_closed" + } + } + + /// Where the paywall was opened from. Always `onboarding` (`OnboardingPaywallDecision/source`). + public var source: String { + OnboardingPaywallDecision.source + } + + /// The `pw_id` that ties together the events of one presentation: eight lowercase hex digits. + public var paywallId: String { + switch self { + case .shown(let paywallId), .closed(let paywallId, _, _): paywallId + } + } + + /// The event's parameters under the catalog's names, with every value as text. + /// + /// `paywall_shown` has `source` and `pw_id`. `paywall_closed` adds `result`, `via`, `open_s`, + /// and `product` when a plan was bought. + public var parameters: [String: String] { + var parameters = ["source": source, "pw_id": paywallId] + if case .closed(_, let exit, let openSeconds) = self { + parameters["result"] = exit.result + parameters["via"] = exit.via + parameters["open_s"] = String(openSeconds) + if let product = exit.product { parameters["product"] = product } + } + return parameters + } + + /// A new `pw_id`: eight lowercase hex digits. + static func makePaywallId() -> String { + String(UUID().uuidString.lowercased().filter { $0 != "-" }.prefix(8)) + } + + /// Whole seconds between the paywall appearing and the customer leaving it, never negative. + /// `0` when it never appeared. + static func openSeconds(from shownAt: Date?, to closedAt: Date) -> Int { + guard let shownAt else { return 0 } + return max(0, Int(closedAt.timeIntervalSince(shownAt).rounded(.down))) + } +} diff --git a/Sources/SubscriptionUI/Onboarding/OnboardingPaywallExit.swift b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallExit.swift new file mode 100644 index 0000000..9fe8575 --- /dev/null +++ b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallExit.swift @@ -0,0 +1,62 @@ +import Foundation +import Subscription + +/// How the customer left the paywall at the end of onboarding. +/// +/// Every exit leads to the same place, the screen onboarding promised, so +/// ``OnboardingPaywallStep`` reports all four through one closure. The cases are here for the +/// analytics and for what the app does next, such as asking for notifications after a purchase. +public enum OnboardingPaywallExit: Sendable, Hashable { + /// Closed with the × in the top-leading corner. + case closeButton + + /// Closed with "Continue for free" beneath the paywall. + case continueFree + + /// Bought a plan that lasts this long. + case purchased(PackageDuration) + + /// Restored a purchase that entitles the customer. + case restored + + /// Whether the customer leaves entitled. + public var isEntitled: Bool { + switch self { + case .purchased, .restored: true + case .closeButton, .continueFree: false + } + } + + /// The `result` of `paywall_closed`: `purchased`, `restored` or `dismissed`. + public var result: String { + switch self { + case .closeButton, .continueFree: "dismissed" + case .purchased: "purchased" + case .restored: "restored" + } + } + + /// The `via` of `paywall_closed`: `close_button`, `continue_free`, or `auto` when the paywall + /// closed itself after a purchase or a restore. + public var via: String { + switch self { + case .closeButton: "close_button" + case .continueFree: "continue_free" + case .purchased, .restored: "auto" + } + } + + /// The `product` of `paywall_closed`: `yearly`, `monthly` or `lifetime`. + /// + /// `nil` unless a plan was bought, and for a plan whose duration this package does not model + /// (`PackageDuration/unknown`), rather than a guess. + public var product: String? { + guard case .purchased(let duration) = self else { return nil } + switch duration { + case .annual: return "yearly" + case .monthly: return "monthly" + case .lifetime: return "lifetime" + case .unknown: return nil + } + } +} diff --git a/Sources/SubscriptionUI/Onboarding/OnboardingPaywallLabels.swift b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallLabels.swift new file mode 100644 index 0000000..14fea9e --- /dev/null +++ b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallLabels.swift @@ -0,0 +1,29 @@ +import Foundation + +/// The words that ``OnboardingPaywallStep`` puts on screen, so an app can localize them. +/// +/// Only the words. Whether the × and "Continue for free" appear, and where, is not configurable: +/// both are always there, from the first frame. +public struct OnboardingPaywallLabels: Sendable { + /// What VoiceOver reads for the × in the top-leading corner. + public var close: String + + /// The button beneath the paywall that closes it without buying. + /// + /// Say that the app goes on for free. "Later" or "Not now" reads as a reminder to come, and + /// is a different event (`via=later`). + public var continueFree: String + + /// Creates a set of labels. English by default. + /// + /// - Parameters: + /// - close: What VoiceOver reads for the ×. + /// - continueFree: The button that closes the paywall without buying. + public init(close: String = "Close", continueFree: String = "Continue for free") { + self.close = close + self.continueFree = continueFree + } + + /// The labels in Japanese. + public static let japanese = OnboardingPaywallLabels(close: "閉じる", continueFree: "無料のまま続ける") +} diff --git a/Sources/SubscriptionUI/Onboarding/OnboardingPaywallStep.swift b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallStep.swift new file mode 100644 index 0000000..73be024 --- /dev/null +++ b/Sources/SubscriptionUI/Onboarding/OnboardingPaywallStep.swift @@ -0,0 +1,152 @@ +import SwiftUI +import Subscription + +/// The last step of onboarding: the app's own paywall, with the ways out that every app must have. +/// +/// The paywall is the app's — its pages, plans, copy and look (this package draws none of it). +/// The step owns what must not differ between apps: +/// +/// - **a × in the top-leading corner**, there from the first frame, never delayed or faded +/// - **"Continue for free" beneath the paywall**, an ordinary button, always shown +/// - **one exit.** Closing with the ×, continuing for free, buying and restoring all end in +/// `onFinish`, exactly once, so onboarding always reaches the screen it promised +/// - **`source=onboarding`** on every event, with one `pw_id` per presentation +/// +/// None of these has a parameter. An app that wants the × elsewhere, or no way to continue for +/// free, is asking for something App Store Review and the studio's rules both say no to. +/// +/// ```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, +/// source: context.source, +/// paywallId: context.paywallId, +/// onWorking: context.setWorking, +/// onPurchased: context.purchased, +/// onRestored: context.restored +/// ) +/// } +/// ``` +/// +/// Whether the step is shown at all is `OnboardingPaywallDecision`'s call: decide before +/// onboarding starts, and leave the step out when it says skip. +/// +/// The step stays put while it is on screen: presented as a sheet, it cannot be swiped away, so the +/// × and "Continue for free" are the only ways out and each one is counted. +public struct OnboardingPaywallStep: View { + private let labels: OnboardingPaywallLabels + private let onEvent: @MainActor (OnboardingPaywallEvent) -> Void + private let onFinish: @MainActor (OnboardingPaywallExit) -> Void + private let paywall: @MainActor (OnboardingPaywallContext) -> Paywall + + @State private var paywallId = OnboardingPaywallEvent.makePaywallId() + @State private var shownAt: Date? + @State private var hasFinished = false + @State private var isWorking = false + + /// Creates the step. + /// + /// - Parameters: + /// - labels: The words on the × and on "Continue for free". English by default; + /// ``OnboardingPaywallLabels/japanese`` for Japanese. + /// - onEvent: Receives `paywall_shown` when the step appears and `paywall_closed` when the + /// customer leaves it, to send to the app's analytics. + /// - onFinish: Run once, however the customer leaves: the place to go on to the screen + /// onboarding promised. + /// - paywall: Builds the app's paywall from the context it reports a purchase or a restore to. + public init( + labels: OnboardingPaywallLabels = OnboardingPaywallLabels(), + onEvent: @escaping @MainActor (OnboardingPaywallEvent) -> Void = { _ in }, + onFinish: @escaping @MainActor (OnboardingPaywallExit) -> Void, + @ViewBuilder paywall: @escaping @MainActor (OnboardingPaywallContext) -> Paywall + ) { + self.labels = labels + self.onEvent = onEvent + self.onFinish = onFinish + self.paywall = paywall + } + + public var body: some View { + paywall(context) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + HStack(spacing: 0) { + closeButton + Spacer(minLength: 0) + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + continueFreeButton + } + .interactiveDismissDisabled() + .onAppear { recordShown() } + } + + private var context: OnboardingPaywallContext { + OnboardingPaywallContext( + paywallId: paywallId, + finish: { exit in finish(exit) }, + working: { working in isWorking = working } + ) + } + + private var closeButton: some View { + Button { + finish(.closeButton) + } label: { + Image(systemName: "xmark") + .font(.body.weight(.semibold)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(labels.close) + .disabled(isWorking || hasFinished) + .padding(.leading, 8) + } + + private var continueFreeButton: some View { + Button { + finish(.continueFree) + } label: { + Text(labels.continueFree) + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(Rectangle()) + } + .disabled(isWorking || hasFinished) + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + + private func recordShown() { + guard shownAt == nil else { return } + shownAt = Date() + onEvent(.shown(paywallId: paywallId)) + } + + private func finish(_ exit: OnboardingPaywallExit) { + guard !hasFinished else { return } + hasFinished = true + isWorking = false + let openSeconds = OnboardingPaywallEvent.openSeconds(from: shownAt, to: Date()) + onEvent(.closed(paywallId: paywallId, exit: exit, openSeconds: openSeconds)) + onFinish(exit) + } +} + +#Preview("Onboarding paywall") { + OnboardingPaywallStep(labels: .japanese, onFinish: { _ in }) { context in + VStack(spacing: 16) { + Image(systemName: "sparkles").font(.system(size: 72)) + Text("Everything, unlocked").font(.largeTitle.bold()) + Button("Buy") { context.purchased(SubscriptionOffering.preview.packages[0]) } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/Tests/SubscriptionTests/OnboardingPaywallDecisionTests.swift b/Tests/SubscriptionTests/OnboardingPaywallDecisionTests.swift new file mode 100644 index 0000000..aa05f6c --- /dev/null +++ b/Tests/SubscriptionTests/OnboardingPaywallDecisionTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Subscription +import Testing + +@Suite("オンボーディングの最後にペイウォールを出すか") +struct OnboardingPaywallDecisionTests { + private static let annual = SubscriptionPackage( + id: "annual", title: "Annual", description: "", price: "¥6,000", pricePerMonth: "¥500", duration: .annual + ) + + private static func offering( + metadata: [String: String] = [:], + droppedMetadataKeys: Set = [], + packages: [SubscriptionPackage] = [annual] + ) -> SubscriptionOffering { + SubscriptionOffering( + id: "default", + packages: packages, + metadata: metadata, + droppedMetadataKeys: droppedMetadataKeys, + servedBy: .placement(OnboardingPaywallDecision.placement) + ) + } + + private static let entitled = SubscriptionStatus(isActive: true, verification: .confirmed(at: Date())) + + @Test("売る物があり、購読していなければ出す") + func showsByDefault() { + let decision = OnboardingPaywallDecision.decide(offering: Self.offering(), status: .inactive) + #expect(decision == .show) + #expect(decision.shows) + } + + @Test("確かめる前の未購読(初回の起動)でも出す") + func showsBeforeVerification() { + let status = SubscriptionStatus(isActive: false, verification: .unverified) + #expect(OnboardingPaywallDecision.decide(offering: Self.offering(), status: status) == .show) + } + + @Test("購読中の人には出さない") + func skipsWhenEntitled() { + let decision = OnboardingPaywallDecision.decide(offering: Self.offering(), status: Self.entitled) + #expect(decision == .skip(.entitled)) + #expect(!decision.shows) + } + + @Test("metadata の onboarding_paywall が off なら出さない", arguments: ["off", "OFF", "Off", " off\n"]) + func skipsWhenSwitchedOff(_ value: String) { + let offering = Self.offering(metadata: ["onboarding_paywall": value]) + #expect(OnboardingPaywallDecision.decide(offering: offering, status: .inactive) == .skip(.switchedOff)) + } + + @Test("off 以外の値・書き損じは出す", arguments: ["on", "", "of", "false", "0", "disabled"]) + func showsForAnyOtherValue(_ value: String) { + let offering = Self.offering(metadata: ["onboarding_paywall": value]) + #expect(OnboardingPaywallDecision.decide(offering: offering, status: .inactive) == .show) + } + + @Test("文字列でない値(JSON の false)は読めないので出す") + func showsWhenValueIsNotText() { + let offering = Self.offering(droppedMetadataKeys: ["onboarding_paywall"]) + #expect(OnboardingPaywallDecision.decide(offering: offering, status: .inactive) == .show) + } + + @Test("ほかの鍵の off では止まらない") + func ignoresOtherKeys() { + let offering = Self.offering(metadata: ["paywall": "off", "appeal": "off"]) + #expect(OnboardingPaywallDecision.decide(offering: offering, status: .inactive) == .show) + } + + @Test("offering が読めなければ出さない") + func skipsWithoutOffering() { + #expect(OnboardingPaywallDecision.decide(offering: nil, status: .inactive) == .skip(.nothingToSell)) + } + + @Test("商品の無い offering では出さない") + func skipsWithoutPackages() { + let offering = Self.offering(packages: []) + #expect(OnboardingPaywallDecision.decide(offering: offering, status: .inactive) == .skip(.nothingToSell)) + } + + @Test("理由が重なったら、購読中・offering 無し・off・商品無しの順に1つを返す") + func reasonOrder() { + let offAndEmpty = Self.offering(metadata: ["onboarding_paywall": "off"], packages: []) + #expect(OnboardingPaywallDecision.decide(offering: offAndEmpty, status: Self.entitled) == .skip(.entitled)) + #expect(OnboardingPaywallDecision.decide(offering: nil, status: Self.entitled) == .skip(.entitled)) + #expect(OnboardingPaywallDecision.decide(offering: offAndEmpty, status: .inactive) == .skip(.switchedOff)) + } + + @Test("名前は計測とダッシュボードの語に合わせる") + func names() { + #expect(OnboardingPaywallDecision.placement == "onboarding") + #expect(OnboardingPaywallDecision.source == "onboarding") + #expect(OnboardingPaywallDecision.metadataKey == "onboarding_paywall") + #expect(OnboardingPaywallSkipReason.allCases.map(\.rawValue) == ["entitled", "switched_off", "nothing_to_sell"]) + } +} diff --git a/Tests/SubscriptionUITests/OnboardingPaywallEventTests.swift b/Tests/SubscriptionUITests/OnboardingPaywallEventTests.swift new file mode 100644 index 0000000..f2aaaea --- /dev/null +++ b/Tests/SubscriptionUITests/OnboardingPaywallEventTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Subscription +import Testing +@testable import SubscriptionUI + +@Suite("オンボーディングの最後のペイウォールの出口と計測") +struct OnboardingPaywallEventTests { + @Test("閉じ方ごとの result と via は共通のカタログの値") + func exitValues() { + #expect(OnboardingPaywallExit.closeButton.result == "dismissed") + #expect(OnboardingPaywallExit.closeButton.via == "close_button") + #expect(OnboardingPaywallExit.continueFree.result == "dismissed") + #expect(OnboardingPaywallExit.continueFree.via == "continue_free") + #expect(OnboardingPaywallExit.purchased(.annual).result == "purchased") + #expect(OnboardingPaywallExit.purchased(.annual).via == "auto") + #expect(OnboardingPaywallExit.restored.result == "restored") + #expect(OnboardingPaywallExit.restored.via == "auto") + } + + @Test("買ったときだけ product を持つ。annual は yearly、知らない長さは無し") + func product() { + #expect(OnboardingPaywallExit.purchased(.annual).product == "yearly") + #expect(OnboardingPaywallExit.purchased(.monthly).product == "monthly") + #expect(OnboardingPaywallExit.purchased(.lifetime).product == "lifetime") + #expect(OnboardingPaywallExit.purchased(.unknown).product == nil) + #expect(OnboardingPaywallExit.restored.product == nil) + #expect(OnboardingPaywallExit.closeButton.product == nil) + #expect(OnboardingPaywallExit.continueFree.product == nil) + } + + @Test("買った・復元したは購読して出る。閉じた2つは購読しないで出る") + func entitled() { + #expect(OnboardingPaywallExit.purchased(.monthly).isEntitled) + #expect(OnboardingPaywallExit.restored.isEntitled) + #expect(!OnboardingPaywallExit.closeButton.isEntitled) + #expect(!OnboardingPaywallExit.continueFree.isEntitled) + } + + @Test("paywall_shown は source=onboarding と pw_id だけ") + func shownEvent() { + let event = OnboardingPaywallEvent.shown(paywallId: "0a1b2c3d") + #expect(event.name == "paywall_shown") + #expect(event.source == "onboarding") + #expect(event.paywallId == "0a1b2c3d") + #expect(event.parameters == ["source": "onboarding", "pw_id": "0a1b2c3d"]) + } + + @Test("×で閉じた paywall_closed") + func closedByButton() { + let event = OnboardingPaywallEvent.closed(paywallId: "0a1b2c3d", exit: .closeButton, openSeconds: 12) + #expect(event.name == "paywall_closed") + #expect(event.parameters == [ + "source": "onboarding", "pw_id": "0a1b2c3d", + "result": "dismissed", "via": "close_button", "open_s": "12" + ]) + } + + @Test("買って閉じた paywall_closed は product を持つ") + func closedByPurchase() { + let event = OnboardingPaywallEvent.closed(paywallId: "0a1b2c3d", exit: .purchased(.annual), openSeconds: 40) + #expect(event.parameters == [ + "source": "onboarding", "pw_id": "0a1b2c3d", + "result": "purchased", "via": "auto", "open_s": "40", "product": "yearly" + ]) + } + + @Test("pw_id は小文字の16進8桁で、毎回違う") + func paywallId() { + let ids = (0..<50).map { _ in OnboardingPaywallEvent.makePaywallId() } + for id in ids { + #expect(id.count == 8) + #expect(id.allSatisfy { "0123456789abcdef".contains($0) }) + } + #expect(Set(ids).count == ids.count) + } + + @Test("open_s は切り捨ての秒。出ていない・時計が戻ったときは0") + func openSeconds() { + let shown = Date(timeIntervalSince1970: 1_800_000_000) + #expect(OnboardingPaywallEvent.openSeconds(from: shown, to: shown.addingTimeInterval(12.9)) == 12) + #expect(OnboardingPaywallEvent.openSeconds(from: shown, to: shown) == 0) + #expect(OnboardingPaywallEvent.openSeconds(from: shown, to: shown.addingTimeInterval(-5)) == 0) + #expect(OnboardingPaywallEvent.openSeconds(from: nil, to: shown) == 0) + } + + @Test("日本語の文言は「無料のまま続ける」") + func japaneseLabels() { + #expect(OnboardingPaywallLabels.japanese.continueFree == "無料のまま続ける") + #expect(OnboardingPaywallLabels.japanese.close == "閉じる") + } +}