diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d1565..fbbb8ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/react-native-superwall/releases) on GitHub. +## 2.1.0 (Beta 1) + +### Enhancements + +- Adds `didRedeem` and `willRedeem` to support web checkout +- Upgrades iOS SDK to 4.3.7 [View iOS SDK release notes](https://github.com/superwall/Superwall-iOS/releases/tag/4.3.7). + + ## 2.0.14 ### Enhancements diff --git a/example/src/MySuperwallDelegate.tsx b/example/src/MySuperwallDelegate.tsx index c1f157c..bb150f6 100644 --- a/example/src/MySuperwallDelegate.tsx +++ b/example/src/MySuperwallDelegate.tsx @@ -5,6 +5,7 @@ import { SuperwallEventInfo, EventType, } from '../../src'; +import type { RedemptionResult } from '../../src/public/RedemptionResults'; export class MySuperwallDelegate extends SuperwallDelegate { subscriptionStatusDidChange( @@ -77,4 +78,12 @@ export class MySuperwallDelegate extends SuperwallDelegate { ): void { console.log(`[${level}] ${scope}: ${message}`, info, error); } + + willRedeemLink(): void { + console.log('Will redeem link'); + } + + didRedeemLink(result: RedemptionResult): void { + console.log('Did redeem link:', result); + } } diff --git a/ios/Bridges/SuperwallDelegateBridge.swift b/ios/Bridges/SuperwallDelegateBridge.swift index 87cdac2..ffdbd86 100644 --- a/ios/Bridges/SuperwallDelegateBridge.swift +++ b/ios/Bridges/SuperwallDelegateBridge.swift @@ -79,4 +79,12 @@ final class SuperwallDelegateBridge: SuperwallDelegate { private func sendEvent(withName name: String, body: [String: Any]) { SuperwallReactNative.emitter.sendEvent(withName: name, body: body) } + + func willRedeemLink() { + sendEvent(withName: "willRedeemLink", body: [:]) + } + + func didRedeemLink(withResult result: RedemptionResult) { + sendEvent(withName: "didRedeemLink", body: result.toJson()) + } } diff --git a/ios/Json/RedemptionResult+Json.swift b/ios/Json/RedemptionResult+Json.swift new file mode 100644 index 0000000..b2dd0b6 --- /dev/null +++ b/ios/Json/RedemptionResult+Json.swift @@ -0,0 +1,138 @@ +// +// RedemptionResult+Json.swift +// SuperwallKit +// +// Created on 14/03/2025. +// + +import SuperwallKit + +extension RedemptionResult { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + switch self { + case let .success(code, redemptionInfo): + map["status"] = "SUCCESS" + map["code"] = code + map["redemptionInfo"] = redemptionInfo.toJson() + case let .error(code, error): + map["status"] = "ERROR" + map["code"] = code + map["error"] = error.toJson() + case let .expiredCode(code, info): + map["status"] = "CODE_EXPIRED" + map["code"] = code + map["expired"] = info.toJson() + case .invalidCode(let code): + map["status"] = "INVALID_CODE" + map["code"] = code + case let .expiredSubscription(code, redemptionInfo): + map["status"] = "EXPIRED_SUBSCRIPTION" + map["code"] = code + map["redemptionInfo"] = redemptionInfo.toJson() + } + + return map + } +} + +extension RedemptionResult.ErrorInfo { + func toJson() -> [String: Any] { + return ["message": self.message] + } +} + +extension RedemptionResult.ExpiredCodeInfo { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + map["resent"] = self.resent + if let obfuscatedEmail = self.obfuscatedEmail { + map["obfuscatedEmail"] = obfuscatedEmail + } + + return map + } +} + +extension RedemptionResult.RedemptionInfo { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + map["ownership"] = self.ownership.toJson() + map["purchaserInfo"] = self.purchaserInfo.toJson() + if let paywallInfo = self.paywallInfo { + map["paywallInfo"] = paywallInfo.toJson() + } + map["entitlements"] = self.entitlements.map { $0.toJson() } + + return map + } +} + +extension RedemptionResult.RedemptionInfo.Ownership { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + switch self { + case .appUser(let appUserId): + map["type"] = "APP_USER" + map["appUserId"] = appUserId + case .device(let deviceId): + map["type"] = "DEVICE" + map["deviceId"] = deviceId + } + + return map + } +} + +extension RedemptionResult.RedemptionInfo.PurchaserInfo { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + map["appUserId"] = self.appUserId + if let email = self.email { + map["email"] = email + } + map["storeIdentifiers"] = self.storeIdentifiers.toJson() + + return map + } +} + +extension RedemptionResult.RedemptionInfo.PurchaserInfo.StoreIdentifiers { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + switch self { + case let .stripe(customerId, subscriptionIds): + map["store"] = "STRIPE" + map["stripeCustomerId"] = customerId + map["stripeSubscriptionIds"] = subscriptionIds + case let .unknown(store, additionalInfo): + map["store"] = store + // Add all the additional info to the map + for (key, value) in additionalInfo { + map[key] = value + } + } + + return map + } +} + +extension RedemptionResult.RedemptionInfo.PaywallInfo { + func toJson() -> [String: Any] { + var map: [String: Any] = [:] + + map["identifier"] = self.identifier + map["placementName"] = self.placementName + map["placementParams"] = self.placementParams + map["variantId"] = self.variantId + map["experimentId"] = self.experimentId + + return map + } +} diff --git a/ios/SuperwallReactNative.swift b/ios/SuperwallReactNative.swift index 1d06d0e..7d8e1df 100644 --- a/ios/SuperwallReactNative.swift +++ b/ios/SuperwallReactNative.swift @@ -17,6 +17,8 @@ class SuperwallReactNative: RCTEventEmitter { "purchaseFromAppStore", "purchaseFromGooglePlay", "paywallWillOpenURL", + "willRedeemLink", + "didRedeemLink", "restore", "paywallPresentationHandler", "subscriptionStatusDidChange", diff --git a/package.json b/package.json index e3eb64d..adaafaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@superwall/react-native-superwall", - "version": "2.0.14", + "version": "2.1.0-beta.1", "description": "The React Native package for Superwall", "main": "lib/commonjs/index", "module": "lib/module/index", diff --git a/src/index.tsx b/src/index.tsx index 22396bb..1b40aa6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -17,6 +17,7 @@ import type { PresentationResult } from './public/PresentationResult'; import { fromJson as paywallResultFromJson } from './public/PaywallResult'; import { EntitlementsInfo } from './public/EntitlementsInfo'; import type { LogLevel } from './public/LogLevel'; +import { RedemptionResults } from './public/RedemptionResults'; const { version } = require('../package.json'); const LINKING_ERROR = @@ -249,6 +250,15 @@ export default class Superwall { const url = new URL(data.url); Superwall.delegate?.paywallWillOpenURL(url); }); + + this.eventEmitter.addListener('willRedeemLink', async () => { + Superwall.delegate?.willRedeemLink(); + }); + + this.eventEmitter.addListener('didRedeemLink', async (data) => { + const result = RedemptionResults.fromJson(data.result); + Superwall.delegate?.didRedeemLink(result); + }); } private async observeSubscriptionStatus() { diff --git a/src/public/RedemptionResults.ts b/src/public/RedemptionResults.ts new file mode 100644 index 0000000..bc5cd9c --- /dev/null +++ b/src/public/RedemptionResults.ts @@ -0,0 +1,216 @@ +/** + * RedemptionResult and related types + * Corresponds to the Swift RedemptionResult enum and its associated types + */ + +import { Entitlement } from './Entitlement'; + +/** + * Information about an error that occurred during code redemption + */ +export interface ErrorInfo { + /** The error message */ + message: string; +} + +/** + * Information about an expired redemption code + */ +export interface ExpiredCodeInfo { + /** Whether the redemption email was resent */ + resent: boolean; + /** Optional obfuscated email address that the redemption email was sent to */ + obfuscatedEmail?: string; +} + +/** + * Represents the ownership of a redemption code + */ +export type Ownership = + | { type: 'APP_USER'; appUserId: string } + | { type: 'DEVICE'; deviceId: string }; + +/** + * Store identifiers for the purchase + */ +export type StoreIdentifiers = + | { + store: 'STRIPE'; + stripeCustomerId: string; + stripeSubscriptionIds: string[]; + } + | { store: string; [key: string]: any }; // For unknown store types + +/** + * Information about the purchaser + */ +export interface PurchaserInfo { + /** The app user ID of the purchaser */ + appUserId: string; + /** The email address of the purchaser (optional) */ + email?: string; + /** The identifiers for the store the purchase was made from */ + storeIdentifiers: StoreIdentifiers; +} + +/** + * Information about the paywall the purchase was made from + */ +export interface PaywallInfo { + /** The identifier of the paywall */ + identifier: string; + /** The name of the placement */ + placementName: string; + /** The params of the placement */ + placementParams: Record; + /** The ID of the paywall variant */ + variantId: string; + /** The ID of the experiment that the paywall belongs to */ + experimentId: string; +} + +/** + * Information about a successful redemption + */ +export interface RedemptionInfo { + /** The ownership of the code */ + ownership: Ownership; + /** Information about the purchaser */ + purchaserInfo: PurchaserInfo; + /** Information about the paywall the purchase was made from (optional) */ + paywallInfo?: PaywallInfo; + /** The entitlements granted by the redemption */ + entitlements: Entitlement[]; +} + +/** + * The result of redeeming a code via web checkout + */ +export type RedemptionResult = + | { status: 'SUCCESS'; code: string; redemptionInfo: RedemptionInfo } + | { status: 'ERROR'; code: string; error: ErrorInfo } + | { status: 'CODE_EXPIRED'; code: string; expired: ExpiredCodeInfo } + | { status: 'INVALID_CODE'; code: string } + | { + status: 'EXPIRED_SUBSCRIPTION'; + code: string; + redemptionInfo: RedemptionInfo; + }; + +/** + * Static methods for working with RedemptionResult + */ +export class RedemptionResults { + static fromJson(json: any): RedemptionResult { + const { status, code } = json; + + switch (status) { + case 'SUCCESS': + return { + status, + code, + redemptionInfo: this.parseRedemptionInfo(json.redemptionInfo), + }; + + case 'ERROR': + return { + status, + code, + error: { + message: json.error.message, + }, + }; + + case 'CODE_EXPIRED': + return { + status, + code, + expired: { + resent: json.expired.resent, + obfuscatedEmail: json.expired.obfuscatedEmail, + }, + }; + + case 'INVALID_CODE': + return { + status, + code, + }; + + case 'EXPIRED_SUBSCRIPTION': + return { + status, + code, + redemptionInfo: this.parseRedemptionInfo(json.redemptionInfo), + }; + + default: + throw new Error(`Unknown RedemptionResult status: ${status}`); + } + } + + private static parseRedemptionInfo(json: any): RedemptionInfo { + const result: RedemptionInfo = { + ownership: this.parseOwnership(json.ownership), + purchaserInfo: this.parsePurchaserInfo(json.purchaserInfo), + entitlements: Array.isArray(json.entitlements) + ? json.entitlements.map((e: any) => Entitlement.fromJson(e)) + : [], + }; + + if (json.paywallInfo) { + result.paywallInfo = { + identifier: json.paywallInfo.identifier, + placementName: json.paywallInfo.placementName, + placementParams: json.paywallInfo.placementParams || {}, + variantId: json.paywallInfo.variantId, + experimentId: json.paywallInfo.experimentId, + }; + } + + return result; + } + private static parseOwnership(json: any): Ownership { + switch (json.type) { + case 'APP_USER': + return { + type: 'APP_USER', + appUserId: json.appUserId, + }; + + case 'DEVICE': + return { + type: 'DEVICE', + deviceId: json.deviceId, + }; + + default: + throw new Error(`Unknown ownership type: ${json.type}`); + } + } + + private static parsePurchaserInfo(json: any): PurchaserInfo { + const result: PurchaserInfo = { + appUserId: json.appUserId, + storeIdentifiers: this.parseStoreIdentifiers(json.storeIdentifiers), + }; + + if (json.email) { + result.email = json.email; + } + + return result; + } + + private static parseStoreIdentifiers(json: any): StoreIdentifiers { + if (json.store === 'STRIPE') { + return { + store: 'STRIPE', + stripeCustomerId: json.stripeCustomerId, + stripeSubscriptionIds: json.stripeSubscriptionIds || [], + }; + } + + return { ...json }; + } +} diff --git a/src/public/SuperwallDelegate.ts b/src/public/SuperwallDelegate.ts index 7520916..2c3000c 100644 --- a/src/public/SuperwallDelegate.ts +++ b/src/public/SuperwallDelegate.ts @@ -1,4 +1,5 @@ import { PaywallInfo } from './PaywallInfo'; +import type { RedemptionResult } from './RedemptionResults'; import { SubscriptionStatus } from './SubscriptionStatus'; import { SuperwallEventInfo } from './SuperwallEventInfo'; @@ -7,6 +8,8 @@ export abstract class SuperwallDelegate { from: SubscriptionStatus, to: SubscriptionStatus ): void; + abstract willRedeemLink(): void; + abstract didRedeemLink(result: RedemptionResult): void; abstract handleSuperwallEvent(eventInfo: SuperwallEventInfo): void; abstract handleCustomPaywallAction(name: string): void; abstract willDismissPaywall(paywallInfo: PaywallInfo): void; diff --git a/superwall-react-native.podspec b/superwall-react-native.podspec index 54aa237..cb014ba 100644 --- a/superwall-react-native.podspec +++ b/superwall-react-native.podspec @@ -15,7 +15,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/superwall/Superwall-React-Native.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,swift}" - s.dependency "SuperwallKit", '4.3.5' + s.dependency "SuperwallKit", '4.3.7' # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0. # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.