diff --git a/CHANGELOG.md b/CHANGELOG.md index 9579d0a..ba11a6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `references/concepts/byos.md` — Bring Your Own Screen reference: when to use it (native login step in a Flow, A/B against an existing paywall, reordering onboarding), Console configuration (Screen ID + connections), iOS implementation (`PLYCustomScreenViewControllerDelegate` / SwiftUI `PLYCustomScreenViewDelegate`), Android implementation (`PLYCustomScreenProvider` + `PLYCustomScreen.View` / `.Fragment`), `executeConnection(...)` / `execute(connection)` contract for resuming a Flow or running a standalone action, `Purchasely.synchronize()` requirement for in-screen purchases, analytics behaviour, and a list of anti-patterns BYOS replaces (custom VC over Purchasely, manual close-then-push, skipping `display()`). iOS + Android only, SDK ≥ 5.6.0. +- `references/concepts/paywall-actions.md` § **Chaining multiple actions on a single button** — documents how a Composer button can carry several actions (purchase + open_screen / open_placement / deeplink / close, login + purchase, etc.) executed sequentially, the default behaviour when no second action is configured (close in Full mode, stay open in Observer mode), how the interceptor sees each action separately, and the fact that `proceed(false)` short-circuits the chain. +- `sdk-expert` agent: new response rule for BYOS / "show my own screen inside a paywall or Flow" / "native login step inside a Flow" — forces loading `references/concepts/byos.md`, gates the answer on SDK ≥ 5.6.0 + iOS/Android platform, and steers users away from anti-patterns (presenting a custom VC over the Purchasely controller, `Purchasely.close()` then push, skipping `display()`). +- `review` skill: new section **3.12 BYOS** — conditional checklist (platform support, SDK version, `display()` usage, delegate/provider completeness, `executeConnection` on every exit, connection ID consistency, `synchronize()` after in-screen purchases, no manual navigation around the SDK controller, in-screen analytics instrumentation). +- `review` skill § 3.3: new checkpoint flagging interceptors that try to override the post-purchase flow (holding the interceptor open, skipping `proceed`, manual `Purchasely.close()`) — recommends configuring a second Composer action instead, or BYOS for a custom next step. +- `integrate` skill § Step 9 (Beyond the Basics): two new entries — BYOS and chained Composer actions — so they surface during onboarding when the user's roadmap matches. + ## [1.0.1] — 2026-05-20 ### Changed diff --git a/purchasely/agents/sdk-expert.md b/purchasely/agents/sdk-expert.md index 12c09b9..a851df4 100644 --- a/purchasely/agents/sdk-expert.md +++ b/purchasely/agents/sdk-expert.md @@ -86,8 +86,9 @@ If the bundled reference is missing a detail, looks stale, or the question depen **Universal concepts** — apply to all 5 platforms (`references/concepts/`): - `running-modes.md` — Full vs Observer -- `paywall-actions.md` — interceptor + `proceed/processAction` +- `paywall-actions.md` — interceptor + `proceed/processAction` + chaining multiple actions on a single button (purchase + open_screen / open_placement / deeplink) - `presentation-types.md` — NORMAL / FALLBACK / DEACTIVATED / CLIENT guard +- `byos.md` — Bring Your Own Screen (native screens inside a Flow — login, custom forms, legacy paywall A/B); iOS + Android only, SDK ≥ 5.6.0 - `presentation-cache.md` — preload + invalidation - `observer-mode-post-purchase.md` — `proceed/processAction → dismiss` ordering - `user-identity.md` — `userLogin` / `userLogout` + anonymous→logged-in merge @@ -149,4 +150,5 @@ Use `Glob` and `Read` tools to access these files when you need precise API sign - **Placement-based Campaign** → app keeps calling `fetchPresentation(placementId)` + `presentPresentation`; the Campaign substitutes the placement's default screen when audience matches. - **Both** → Campaign fires on trigger AND can override on placement; the SDK handles routing. Always mention `readyToOpenDeeplink(true)` for trigger-based, the `CAMPAIGN_TRIGGERED` / `CAMPAIGN_DISPLAYED` / `CAMPAIGN_NOT_DISPLAYED` analytics events, and SDK ≥ 5.1.0. Never claim "the campaign activates automatically through `fetchPresentation`" — that conflates the two delivery modes. -13. **For Console-driven questions** — campaigns, audiences, A/B tests, placement configuration, Screen Composer, scheduling, capping, Flows, surveys, or anything the user configures in the Purchasely Console rather than in code: the local references are the fast path but Console behavior evolves quickly. BEFORE answering, fetch the current official doc with `ctx_fetch_and_index(url: "https://docs.purchasely.com/docs/", source: "purchasely--doc")` then `ctx_search(...)` against it, and reconcile with the bundled reference. Useful entry points: `https://docs.purchasely.com/llms.txt` (full index for AI agents), `/docs/campaigns`, `/docs/campaign-configuration`, `/docs/campaigns-implementation`, `/docs/audiences`, `/docs/ab-tests`, `/docs/displaying-screens-placements`, `/docs/screens`, `/docs/flows`. If the doc fetch and the local reference disagree, trust the online doc and flag the discrepancy in your answer so the reference can be updated. +13. **For BYOS / "show my own screen inside a paywall or Flow" / "native login step inside a Flow" / "embed my legacy paywall as a Purchasely variant"**: load `references/concepts/byos.md` FIRST. Confirm SDK ≥ 5.6.0 and the platform is iOS (Swift/SwiftUI) or Android (Kotlin) — BYOS is **not** available on React Native, Flutter, or Cordova yet. Steer users away from anti-patterns (presenting their own VC over the Purchasely controller, calling `Purchasely.close()` then pushing their screen, skipping `display()`). The supported path is: Console creates a Screen with layout `Bring Your Own Screen` and connections; the app sets `Purchasely.setCustomScreenViewControllerDelegate(...)` / `setCustomScreenViewDelegate(...)` (iOS) or `setCustomScreenProvider(...)` (Android); when the user finishes the step, the app calls `presentation.executeConnection(...)` / `presentation.execute(connection)` with the matching `PLYConnection`. Remind callers to `Purchasely.synchronize()` after any purchase performed inside a Custom Screen (especially for A/B and A/A tests). +14. **For Console-driven questions** — campaigns, audiences, A/B tests, placement configuration, Screen Composer, scheduling, capping, Flows, surveys, or anything the user configures in the Purchasely Console rather than in code: the local references are the fast path but Console behavior evolves quickly. BEFORE answering, fetch the current official doc with `ctx_fetch_and_index(url: "https://docs.purchasely.com/docs/", source: "purchasely--doc")` then `ctx_search(...)` against it, and reconcile with the bundled reference. Useful entry points: `https://docs.purchasely.com/llms.txt` (full index for AI agents), `/docs/campaigns`, `/docs/campaign-configuration`, `/docs/campaigns-implementation`, `/docs/audiences`, `/docs/ab-tests`, `/docs/displaying-screens-placements`, `/docs/screens`, `/docs/flows`. If the doc fetch and the local reference disagree, trust the online doc and flag the discrepancy in your answer so the reference can be updated. diff --git a/purchasely/references/concepts/README.md b/purchasely/references/concepts/README.md index e6a1d1e..4a40cb3 100644 --- a/purchasely/references/concepts/README.md +++ b/purchasely/references/concepts/README.md @@ -11,8 +11,9 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | File | Topic | |------|-------| | [running-modes.md](running-modes.md) | Full vs Observer modes, log levels | -| [paywall-actions.md](paywall-actions.md) | `PLYPresentationAction` enum + interceptor `proceed/processAction` rules | +| [paywall-actions.md](paywall-actions.md) | `PLYPresentationAction` enum + interceptor `proceed/processAction` rules + chaining multiple actions on a single button (purchase + open_screen / open_placement / deeplink) | | [presentation-types.md](presentation-types.md) | `PLYPresentationType` enum (NORMAL / FALLBACK / DEACTIVATED / CLIENT) guard | +| [byos.md](byos.md) | Bring Your Own Screen — embed native screens (login, custom forms, legacy paywall) inside a Flow; iOS + Android only, SDK ≥ 5.6.0 | | [presentation-cache.md](presentation-cache.md) | App-side caching + preload pattern (avoid `FlowsManager.flowSteps` accumulation) | | [observer-mode-post-purchase.md](observer-mode-post-purchase.md) | `proceed → dismiss` ordering, native vs bridge close APIs, chaining follow-up placements | | [user-attributes-targeting.md](user-attributes-targeting.md) | Setting user attributes for audience targeting + GDPR consent | @@ -39,4 +40,6 @@ When a topic also has a deeper platform-specific take (e.g. SwiftUI lifecycle, J | Wiring analytics / tracking | `analytics-integration.md`, `user-identity.md` | | Improving paywall perceived performance | `presentation-cache.md` (preload pattern) | | Debugging stuck paywalls / blank presentations | `presentation-types.md`, `presentation-cache.md`, `paywall-actions.md` | +| Embedding a native login / custom form / legacy paywall inside a Flow | `byos.md` (iOS + Android, SDK ≥ 5.6.0) | +| Configuring multi-step buttons (purchase + next step, purchase + placement) | `paywall-actions.md` § Chaining multiple actions | | Reviewing an existing integration | all of the above | diff --git a/purchasely/references/concepts/byos.md b/purchasely/references/concepts/byos.md new file mode 100644 index 0000000..632c44a --- /dev/null +++ b/purchasely/references/concepts/byos.md @@ -0,0 +1,219 @@ +# Bring Your Own Screen (BYOS) — Universal Concept + +Applies to: **iOS (Swift/SwiftUI) and Android (Kotlin)** — SDK **v5.6.0+** mandatory, **`display()` method required**. React Native / Flutter / Cordova support is planned but not shipped yet. + +**BYOS** lets you embed your own native screens directly inside Purchasely Flows (or as standalone steps), so you keep Purchasely's orchestration, navigation, A/B tests and analytics while rendering UI that the Screen Composer cannot build (sign-in, sign-up, text-field forms, legacy paywalls, anything with platform-specific UI). + +## When to use BYOS + +| Scenario | Why BYOS | +|----------|----------| +| A **login / sign-up step inside a Flow** | The Composer cannot host secure text fields or social SDKs. BYOS lets the app own that step. | +| **A/B test** between an existing native paywall and a Purchasely paywall | Plug the legacy paywall as a BYOS variant — no need to rebuild it. | +| **A/A test** between an existing paywall and its Composer port | Compare the two under identical conditions. | +| **Reorder onboarding steps without code** | Move native screens around in the Console between Purchasely Screens. | +| You want to **show your own screen instead of the Purchasely paywall** for a given placement | BYOS is the supported way to do this. Do **not** push a controller over the Purchasely VC, close the Purchasely VC manually, or skip `display()` — BYOS exists exactly for this. | + +## How it works (handover model) + +1. The app fetches a Screen / Flow as usual (`fetchPresentation(...)` then `display()`). +2. When the SDK reaches a step whose layout is **Bring Your Own Screen**, it does **not** render it. Instead it invokes your delegate (iOS) / provider (Android) with a `PLYPresentation` carrying: + - `id` — the Screen ID configured in the Console. + - `connections` — the list of exit points (each `PLYConnection` has an `id` like `login_successful`, `signup`, `cancel`). +3. Your code instantiates the matching native `UIViewController` / `View` / `Fragment` and returns it. +4. The SDK inserts that view into its navigation layer with the **transition configured in the Console** (modal, push, drawer, full screen, pop-in…). You do **not** present it yourself. +5. While the screen is visible, your app owns all interactions (text input, API calls, validation). +6. When the user completes the step, your code calls **`executeConnection(...)` / `execute(connection)`** on the `PLYPresentation`, passing the matching connection. The SDK then resumes the Flow at the mapped next step. + +## Console configuration (recap) + +1. Screen Composer → **Create Screen** → layout **Bring Your Own Screen**. +2. Set a **Screen ID** (e.g. `login`, `signup`, `legacy_paywall`) — communicate it to mobile engineers verbatim. +3. Attach a screenshot as the background image so the screen is recognisable in Flow diagrams. +4. Define **Connections** (e.g. `login_successful`, `signup`, `cancel`) — these are the exit points. Mobile engineers need the exact IDs. +5. Insert the Custom Screen anywhere in a Flow and configure transitions on incoming/outgoing connections. + +A single Flow can chain multiple Custom Screens (e.g. a multi-step sign-up). + +📚 Console guide: + +## iOS implementation + +You can serve either a `UIViewController` (UIKit) **or** a SwiftUI `View`. Both delegates can coexist — the UIKit delegate is called first; if it returns `nil`, the SDK falls back to the SwiftUI one. If neither returns a view, the SDK closes the screen. + +### Delegate protocols + +```swift +@objc public protocol PLYCustomScreenViewControllerDelegate { + @objc func viewController(for presentation: PLYPresentation) -> UIViewController? +} + +public protocol PLYCustomScreenViewDelegate { + associatedtype Content: View + @ViewBuilder func view(for presentation: PLYPresentation) -> Content +} +``` + +### Registering (after `Purchasely.start(...)`) + +```swift +Purchasely.setCustomScreenViewControllerDelegate(myUIKitDelegate) // UIKit +Purchasely.setCustomScreenViewDelegate(mySwiftUIDelegate) // SwiftUI + +// Clearing +Purchasely.removeCustomScreenViewControllerDelegate() +Purchasely.removeCustomScreenViewDelegate() +``` + +### Example (SwiftUI + executeConnection) + +```swift +public class CustomScreenViewDelegate: PLYCustomScreenViewDelegate { + @ViewBuilder + public func view(for presentation: PLYPresentation) -> some View { + switch presentation.id { + case "login": + VStack { + Spacer() + Text("Hello Purchasely!") + Spacer() + Button("Sign in") { + let connection = presentation.connections.first(where: { $0.id == "login_successful" }) + presentation.executeConnection(connection) + } + } + default: + EmptyView() // SDK closes the screen + } + } +} +``` + +### Example (UIKit) + +```swift +final class CustomScreenDelegate: NSObject, PLYCustomScreenViewControllerDelegate { + func viewController(for presentation: PLYPresentation) -> UIViewController? { + switch presentation.id { + case "login": + let vc = LoginViewController() + vc.onSuccess = { + let connection = presentation.connections.first(where: { $0.id == "login_successful" }) + presentation.executeConnection(connection) + } + return vc + default: + return nil + } + } +} +``` + +## Android implementation + +You implement a `PLYCustomScreenProvider` that returns a `PLYCustomScreen`, which can wrap either an Android `View` **or** a `Fragment`. + +### Provider + sealed class + +```kotlin +sealed class PLYCustomScreen { + data class View(val view: android.view.View) : PLYCustomScreen() + data class Fragment(val fragment: androidx.fragment.app.Fragment) : PLYCustomScreen() +} + +interface PLYCustomScreenProvider { + fun onCustomScreenRequested(presentation: PLYPresentation): PLYCustomScreen? +} +``` + +### Registering (after `Purchasely.start(...)`) + +```kotlin +Purchasely.setCustomScreenProvider(myProvider) +// To clear: Purchasely.setCustomScreenProvider(null) +``` + +### Example (View + execute) + +```kotlin +class MyProvider(private val context: Context) : PLYCustomScreenProvider { + override fun onCustomScreenRequested(presentation: PLYPresentation): PLYCustomScreen? { + return when (presentation.id) { + "login" -> { + val connection = presentation.connections.firstOrNull { it.id == "login_successful" } + val loginView = TextView(context).apply { + text = "Sign in" + setOnClickListener { presentation.execute(connection) } + } + PLYCustomScreen.View(loginView) + } + else -> null + } + } +} +``` + +### Example (Fragment) + +```kotlin +override fun onCustomScreenRequested(presentation: PLYPresentation): PLYCustomScreen? { + return when (presentation.id) { + "signup" -> PLYCustomScreen.Fragment(SignupFragment().apply { + onComplete = { connection -> + presentation.execute(presentation.connections.firstOrNull { it.id == connection }) + } + }) + else -> null + } +} +``` + +## `executeConnection(...)` — exiting the Custom Screen + +| Platform | Signature | +|----------|-----------| +| iOS | `presentation.executeConnection(_ connection: PLYConnection?)` | +| Android | `presentation.execute(connection: PLYConnection? = null)` | + +- Pass the matching connection (e.g. `login_successful`, `cancel`) and the SDK resumes the Flow at the next mapped step. +- Pass `nil` / omit the argument to fall back to the presentation's **default connection** if one is configured. +- **Inside a Flow** → the SDK transitions to the next Screen using the connection's configured transition. +- **Outside a Flow (standalone)** → the SDK runs the action attached to that connection: `Purchase`, `Open Screen`, `Open Placement`, `Deeplink`, `Close`, `Close all`, etc. This makes BYOS usable as a one-off custom paywall replacement, not just as a Flow step. + +## Standalone usage (no Flow) + +You can drive a Custom Screen on its own — display it via the normal `fetchPresentation(...)` + `display()` path. The delegate/provider is invoked, you build the view, the user taps a button, and you call `executeConnection(...)` with the matching connection. The connection's configured action runs (e.g. open the next placement, close the experience). + +## Synchronizing purchases performed in a Custom Screen + +If your Custom Screen runs its own billing flow (legacy paywall variant, etc.), call `Purchasely.synchronize()` after a successful transaction so the SDK refreshes its receipt cache and emits the correct events. **Critical for A/B and A/A tests** — without it, conversions are not attributed to the experiment. + +```swift +Purchasely.synchronize() +``` + +```kotlin +Purchasely.synchronize() +``` + +## Analytics & tracking + +- The SDK emits `PRESENTATION_DISPLAYED` for every Custom Screen, with the Screen ID in `displayed_presentation`. Drop-off, transitions, and Flow paths are tracked automatically. +- **Interactions inside the Custom Screen are not tracked by the SDK** — instrument them in your own analytics layer (Firebase, Amplitude, AppsFlyer, etc.). + +## Common mistakes BYOS solves + +| Anti-pattern | Why it breaks | Fix | +|--------------|---------------|-----| +| "Present my own VC over the Purchasely paywall" | The Purchasely VC is still in the navigation stack — back gestures, dismissals, deeplinks behave incorrectly; Console transitions are bypassed; tracking is missing. | Use BYOS so the SDK owns the navigation. | +| "Call `Purchasely.close()` then push my screen" | Closes the entire experience — the Flow ends, analytics lose context, A/B test sample is wrong. | Use BYOS; the SDK keeps the experience alive and resumes after `executeConnection(...)`. | +| "Skip `display()` and render the Composer screen myself from JSON" | Unsupported; you reimplement navigation, transitions, dismissals, deeplinks, tracking, A/B routing. | Use BYOS — the SDK still renders Purchasely screens, you only own the custom ones. | + +## See also + +- [paywall-actions.md](paywall-actions.md) — interceptor contract for **rendered** Purchasely Screens (BYOS has its own `executeConnection` contract instead) +- [presentation-types.md](presentation-types.md) — `PLYPresentationType` (BYOS Custom Screens flow through `display()` like any other presentation) +- [sdk-versions.md](../sdk-versions.md) — confirm the integrated SDK is ≥ 5.6.0 +- [Official docs — BYOS overview](https://docs.purchasely.com/docs/byos) +- [Official docs — BYOS configuration](https://docs.purchasely.com/docs/byos-configuration) +- [Official docs — BYOS implementation](https://docs.purchasely.com/docs/byos-implementation) diff --git a/purchasely/references/concepts/paywall-actions.md b/purchasely/references/concepts/paywall-actions.md index 7f2a7c0..0d6647d 100644 --- a/purchasely/references/concepts/paywall-actions.md +++ b/purchasely/references/concepts/paywall-actions.md @@ -127,14 +127,38 @@ Purchasely.setPaywallActionInterceptor(result => { | `restore` | `proceed(true)` — SDK restores. | Run your own restore, then `proceed(success)`. | | `login` | App handles. SDK then re-fetches with the new user. | Same. | +## Chaining multiple actions on a single button + +A button in the Screen Composer can carry **more than one action**. The actions execute **sequentially** once the first one completes successfully. This is configured in the Console (Screen Composer → button → Actions), **not in the SDK code**. + +Typical chains: + +| First action | Second action | Result | +|--------------|---------------|--------| +| `purchase` | *(none)* | Default: closes the presentation in **Full mode**, does nothing in **Observer mode** (the paywall stays open — your app decides what's next). | +| `purchase` | `open_screen` (next Flow step) | After successful purchase, the SDK advances the Flow to the next Screen. | +| `purchase` | `open_placement` | After successful purchase, the SDK fetches & displays the configured placement (e.g. an upsell, a thank-you screen). | +| `purchase` | `navigate` (deeplink) | After successful purchase, the SDK fires the deeplink. The app handles it via the interceptor (`navigate` action) or the deeplink listener. | +| `purchase` | `close` | Forces the dismiss even if the default would be to stay open (Observer). | +| `login` | `purchase` | After login completes (your `proceed(true)`), the SDK runs the purchase. | + +Key points: + +- **Default after `purchase` is intentional.** In Full mode the SDK closes the paywall on success so the user lands back in the app. In Observer mode the SDK has no opinion — it doesn't know what the app's purchase flow returned — so it leaves the paywall in place. If you want a different behaviour, **add a second action in the Composer**, don't try to coerce it from the interceptor. +- **The interceptor sees only the action being executed at this moment.** For a `purchase + open_placement` chain, you receive `purchase` first (call `proceed(true)`); the SDK then triggers the second action on its own and you receive it as a separate interceptor call (e.g. `open_presentation`). +- **`proceed(false)` short-circuits the chain.** If your purchase branch ends with `proceed(false)` (cancelled / failed / Observer-mode declined), the second action is **not** executed. +- **Configuration is a Console concern.** Mobile engineers cannot add a "second action" from the SDK — ask the team running the Screen Composer to wire it in the button's Actions list. + ## Anti-patterns - ❌ Calling `proceed` / `processAction` inside an async block whose error path doesn't call it. - ❌ Returning from the interceptor without calling `proceed` (e.g. `if (cond) return;`). - ❌ Calling `proceed` twice (e.g. once in the happy path, once in `finally`). - ❌ Doing heavy synchronous work in the interceptor — the paywall is waiting on you. +- ❌ Trying to "stay on the paywall after purchase" by holding the interceptor open or skipping `proceed` — instead, configure the button with no second action (Observer mode) or add an explicit `open_screen` / `open_placement` step. ## See also - [observer-mode-post-purchase.md](observer-mode-post-purchase.md) — exact `proceed → dismiss` sequence after Observer-mode purchases - [presentation-types.md](presentation-types.md) — what to do when `fetchPresentation` returns `DEACTIVATED` or `CLIENT` +- [byos.md](byos.md) — Bring Your Own Screen: native screens inside a Flow, with their own `executeConnection(...)` chaining model diff --git a/purchasely/skills/integrate/SKILL.md b/purchasely/skills/integrate/SKILL.md index 5210d42..5997a46 100644 --- a/purchasely/skills/integrate/SKILL.md +++ b/purchasely/skills/integrate/SKILL.md @@ -1017,6 +1017,8 @@ Once Steps 1-8 are in place and verified, walk the user through the **optional b | **Analytics integration** — forward Purchasely UI events to Firebase / Amplitude / AppsFlyer (client-side) and subscription lifecycle events via 3rd-party integrations / webhooks (server-side, recommended). | Any team with an analytics stack — recommend a single analytics wrapper / manager to centralise the routing | `../../references/concepts/analytics-integration.md` | | **Subscription gating + restore** — gate premium content via `userSubscriptions`, restore purchases from Settings | Any app with premium features | `../../references/concepts/subscription-checks.md` | | **Audience attributes + GDPR consent** — target users with `setUserAttribute`, gate event flow on consent | Apps with marketing audiences or EU users | `../../references/concepts/user-attributes-targeting.md` | +| **Bring Your Own Screen (BYOS)** — embed a native screen (login, custom form, legacy paywall A/B variant) inside a Purchasely Flow with its own connections / `executeConnection(...)` chaining. **iOS + Android only, SDK ≥ 5.6.0.** | Teams that need a native login step in a Flow, or want to A/B their existing paywall against a Composer version | `../../references/concepts/byos.md` | +| **Chain multiple actions on a single button** — configure `purchase + open_screen` / `purchase + open_placement` / `purchase + deeplink` in the Screen Composer. Without a second action, the default is *close in Full mode, stay open in Observer mode*. | Any team wiring post-purchase upsells, thank-you screens, or onboarding completion | `../../references/concepts/paywall-actions.md` § Chaining multiple actions | Pick the ones the user's roadmap actually needs — don't push all six on day one. diff --git a/purchasely/skills/review/SKILL.md b/purchasely/skills/review/SKILL.md index a462931..cbeacc8 100644 --- a/purchasely/skills/review/SKILL.md +++ b/purchasely/skills/review/SKILL.md @@ -148,6 +148,7 @@ For each item below, search the code, analyze the context, and report one of: - [ ] **CLOSE action handled** — The close action must dismiss the paywall and call `processAction(true)`. FAIL if missing (users cannot close the paywall). - [ ] **processAction() ALWAYS called** — Every code path through the interceptor MUST call `processAction()` / `proceed()`. If any branch (early return, error catch, switch default) skips it, the paywall UI will freeze permanently. This is the #1 most common Purchasely bug. FAIL if any code path can skip it. - [ ] **No double calls to processAction()** — Calling `processAction()` twice causes undefined behavior. WARNING if there's a risk of double invocation. +- [ ] **No attempt to override post-purchase flow from the interceptor** — If the app holds the interceptor open, skips `proceed`, or calls `Purchasely.close()` manually to "stay on the paywall" / "show a custom thank-you screen" after a purchase, that's the wrong layer. The Composer button supports a **second action** (`purchase + open_screen` / `purchase + open_placement` / `purchase + deeplink`) and the default is *close in Full mode, stay open in Observer mode*. WARNING — recommend wiring the second action in the Console (or BYOS if the next screen is custom). See `../../references/concepts/paywall-actions.md` § Chaining multiple actions. ### 3.4 Deeplinks @@ -212,6 +213,20 @@ SKIP if the app does not surface promotional offers, developer-determined offers - [ ] **Full mode auto-handles** — in Full mode, no app code is needed. WARNING if app code calls `purchaseWithPromotionalOffer` manually while in Full mode (duplicates the purchase). - [ ] **Observer/custom paywall uses `subscriptionOffer` parameters** — `subscriptionId`, `basePlanId`, `offerId`, `offerToken` (Google) or signed offer (Apple). FAIL if a Promo offer purchase is attempted with regular `purchase(...)` instead of the offer-aware API. +### 3.12 Bring Your Own Screen — BYOS (if a Custom Screen delegate / provider is registered, or a Flow contains a Custom Screen step) + +SKIP if no BYOS code path is detected (no `setCustomScreenViewControllerDelegate` / `setCustomScreenViewDelegate` / `setCustomScreenProvider` registration, no `executeConnection` / `execute(connection:)` call, and the Console does not declare any `Bring Your Own Screen` layout). To detect: search for `CustomScreen`, `PLYCustomScreen`, `executeConnection`, `PLYConnection` in the code. Otherwise: + +- [ ] **Platform supports BYOS** — BYOS is iOS (Swift/SwiftUI) and Android (Kotlin) only. **FAIL** if BYOS is being attempted on React Native, Flutter, or Cordova (not shipped yet — escalate to support before promising it). +- [ ] **SDK ≥ 5.6.0** — required for the Custom Screen delegate/provider APIs and `executeConnection`. FAIL if pinned below. +- [ ] **`display()` is used to render the Flow** — BYOS only triggers when the SDK owns the navigation. FAIL if the app fetches the presentation then renders Composer screens manually (the BYOS callback is never invoked in that path). See `../../references/concepts/byos.md`. +- [ ] **Delegate/provider returns a view for every declared Custom Screen ID** — The Console's Screen ID(s) must each have a matching branch. FAIL if any Screen ID falls through to `nil` / `EmptyView` unintentionally — the SDK silently closes that step and the Flow breaks. +- [ ] **`executeConnection` / `execute(connection)` is called on every exit path** — Every user-driven exit from the Custom Screen (success, cancel, error) must call the SDK with the matching `PLYConnection`. FAIL if any path leaves the screen on its own (e.g. `dismiss()`, `popBackStack()`) without notifying the SDK — the Flow stays stuck or the SDK loses analytics context. +- [ ] **Connection IDs match the Console** — The string IDs (`login_successful`, `signup`, `cancel`…) must match exactly what the Console operator configured. WARNING if hardcoded IDs drift from the Console — recommend a shared constants file. +- [ ] **`Purchasely.synchronize()` after in-screen purchases** — If the Custom Screen runs its own purchase flow (legacy paywall A/B variant, etc.), the app must call `synchronize()` on success. FAIL if missing — A/B / A/A conversion attribution will be wrong. +- [ ] **No manual navigation around the Purchasely controller** — Flag any sign that the team is presenting their own VC over the Purchasely paywall, calling `Purchasely.close()` then pushing a screen, or skipping `display()` to render a custom screen instead. WARNING — replace with BYOS (the supported handover model). +- [ ] **Interaction analytics instrumented in-app** — The SDK emits `PRESENTATION_DISPLAYED` for the Custom Screen but does not track interactions inside it. WARNING if the team relies on Purchasely tracking for in-screen events — they need to wire their own analytics inside the Custom Screen. + ### 3.11 Analytics & Events Forwarding (universal — low blocker, high payoff) - [ ] **One analytics wrapper / manager / controller** — if the project forwards Purchasely events (`PLYEventDelegate` / `EventListener` / `addEventListener`) into Firebase / Amplitude / AppsFlyer, the recommended pattern is a single class that routes events to N vendor SDKs. WARNING if events are forwarded directly from multiple call sites or scattered across screens. SKIP if no client-side event forwarding is in place (server-side 3rd-party integrations may be sufficient — see `../../references/concepts/analytics-integration.md`).