diff --git a/.yarn/cache/react-native-iap-npm-12.4.4-a063846c19-f5c71ba006.zip b/.yarn/cache/react-native-iap-npm-12.4.4-a063846c19-f5c71ba006.zip new file mode 100644 index 000000000..b0dabe0bd Binary files /dev/null and b/.yarn/cache/react-native-iap-npm-12.4.4-a063846c19-f5c71ba006.zip differ diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts index 5ec3c1ffe..4495f5595 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiOperations.ts @@ -3,4 +3,5 @@ export enum SubscriptionApiOperations { CancelingInvite, ListingInvites, AcceptingInvite, + ConfirmAppleIAP, } diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts index 4a71cec1e..2ab8d6edb 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.ts @@ -11,6 +11,8 @@ import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/Su import { SubscriptionApiServiceInterface } from './SubscriptionApiServiceInterface' import { SubscriptionApiOperations } from './SubscriptionApiOperations' import { Uuid } from '@standardnotes/common' +import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse' +import { AppleIAPConfirmRequestParams } from '../../Request' export class SubscriptionApiService implements SubscriptionApiServiceInterface { private operationsInProgress: Map @@ -31,11 +33,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface { [ApiEndpointParam.ApiVersion]: ApiVersion.v0, }) - this.operationsInProgress.set(SubscriptionApiOperations.ListingInvites, false) - return response } catch (error) { throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.ListingInvites, false) } } @@ -52,11 +54,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface { inviteUuid, }) - this.operationsInProgress.set(SubscriptionApiOperations.CancelingInvite, false) - return response } catch (error) { throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.CancelingInvite, false) } } @@ -73,11 +75,11 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface { identifier: inviteeEmail, }) - this.operationsInProgress.set(SubscriptionApiOperations.Inviting, false) - return response } catch (error) { throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.Inviting, false) } } @@ -93,11 +95,27 @@ export class SubscriptionApiService implements SubscriptionApiServiceInterface { inviteUuid, }) - this.operationsInProgress.set(SubscriptionApiOperations.AcceptingInvite, false) - return response } catch (error) { throw new ApiCallError(ErrorMessage.GenericFail) + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.AcceptingInvite, false) + } + } + + async confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise { + if (this.operationsInProgress.get(SubscriptionApiOperations.ConfirmAppleIAP)) { + throw new ApiCallError(ErrorMessage.GenericInProgress) + } + + this.operationsInProgress.set(SubscriptionApiOperations.ConfirmAppleIAP, true) + + try { + const response = await this.subscriptionServer.confirmAppleIAP(params) + + return response + } finally { + this.operationsInProgress.set(SubscriptionApiOperations.ConfirmAppleIAP, false) } } } diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts index e3e14875d..bfb76bea1 100644 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts +++ b/packages/api/src/Domain/Client/Subscription/SubscriptionApiServiceInterface.ts @@ -1,5 +1,7 @@ import { Uuid } from '@standardnotes/common' +import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse' +import { AppleIAPConfirmRequestParams } from '../../Request' import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse' import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse' import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse' @@ -10,4 +12,5 @@ export interface SubscriptionApiServiceInterface { listInvites(): Promise cancelInvite(inviteUuid: Uuid): Promise acceptInvite(inviteUuid: Uuid): Promise + confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise } diff --git a/packages/api/src/Domain/Http/HttpService.spec.ts b/packages/api/src/Domain/Http/HttpService.spec.ts index 16ca967b8..9d8152f66 100644 --- a/packages/api/src/Domain/Http/HttpService.spec.ts +++ b/packages/api/src/Domain/Http/HttpService.spec.ts @@ -10,7 +10,11 @@ describe('HttpService', () => { const host = 'http://bar' let updateMetaCallback: (meta: HttpResponseMeta) => void - const createService = () => new HttpService(environment, appVersion, snjsVersion, host, updateMetaCallback) + const createService = () => { + const service = new HttpService(environment, appVersion, snjsVersion, updateMetaCallback) + service.setHost(host) + return service + } beforeEach(() => { updateMetaCallback = jest.fn() diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts index 6a413978e..effa634c8 100644 --- a/packages/api/src/Domain/Http/HttpService.ts +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -14,12 +14,12 @@ import { HttpErrorResponseBody } from './HttpErrorResponseBody' export class HttpService implements HttpServiceInterface { private authorizationToken?: string private __latencySimulatorMs?: number + private host!: string constructor( private environment: Environment, private appVersion: string, private snjsVersion: string, - private host: string, private updateMetaCallback: (meta: HttpResponseMeta) => void, ) {} diff --git a/packages/api/src/Domain/Request/Subscription/AppleIAPConfirmRequestParams.ts b/packages/api/src/Domain/Request/Subscription/AppleIAPConfirmRequestParams.ts new file mode 100644 index 000000000..b9645b1b1 --- /dev/null +++ b/packages/api/src/Domain/Request/Subscription/AppleIAPConfirmRequestParams.ts @@ -0,0 +1,7 @@ +export type AppleIAPConfirmRequestParams = { + productId: string + transactionId: string + transactionDate: string + transactionReceipt: string + subscription_token: string +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts index 7b4fe6eb6..cc5c47220 100644 --- a/packages/api/src/Domain/Request/index.ts +++ b/packages/api/src/Domain/Request/index.ts @@ -1,4 +1,5 @@ export * from './ApiEndpointParam' +export * from './Subscription/AppleIAPConfirmRequestParams' export * from './Subscription/SubscriptionInviteAcceptRequestParams' export * from './Subscription/SubscriptionInviteCancelRequestParams' export * from './Subscription/SubscriptionInviteDeclineRequestParams' diff --git a/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponse.ts b/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponse.ts new file mode 100644 index 000000000..39a23f745 --- /dev/null +++ b/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponse.ts @@ -0,0 +1,9 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' +import { AppleIAPConfirmResponseBody } from './AppleIAPConfirmResponseBody' + +export interface AppleIAPConfirmResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponseBody.ts b/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponseBody.ts new file mode 100644 index 000000000..58b07399e --- /dev/null +++ b/packages/api/src/Domain/Response/Subscription/AppleIAPConfirmResponseBody.ts @@ -0,0 +1 @@ +export type AppleIAPConfirmResponseBody = { success: true } | { success: false; message: string } diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts index b17f58901..96d61dc6f 100644 --- a/packages/api/src/Domain/Response/index.ts +++ b/packages/api/src/Domain/Response/index.ts @@ -1,3 +1,5 @@ +export * from './Subscription/AppleIAPConfirmResponse' +export * from './Subscription/AppleIAPConfirmResponseBody' export * from './Subscription/SubscriptionInviteAcceptResponse' export * from './Subscription/SubscriptionInviteAcceptResponseBody' export * from './Subscription/SubscriptionInviteCancelResponse' diff --git a/packages/api/src/Domain/Server/Subscription/Paths.ts b/packages/api/src/Domain/Server/Subscription/Paths.ts index 720fc3c3c..73cbca6cf 100644 --- a/packages/api/src/Domain/Server/Subscription/Paths.ts +++ b/packages/api/src/Domain/Server/Subscription/Paths.ts @@ -8,8 +8,13 @@ const SharingPaths = { listInvites: '/v1/subscription-invites', } +const ApplePaths = { + confirmAppleIAP: '/v1/subscriptions/apple_iap_confirm', +} + export const Paths = { v1: { ...SharingPaths, + ...ApplePaths, }, } diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts index 23abcf5d7..92b85cf83 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.ts @@ -1,4 +1,6 @@ +import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse' import { HttpServiceInterface } from '../../Http/HttpServiceInterface' +import { AppleIAPConfirmRequestParams } from '../../Request' import { SubscriptionInviteAcceptRequestParams } from '../../Request/Subscription/SubscriptionInviteAcceptRequestParams' import { SubscriptionInviteCancelRequestParams } from '../../Request/Subscription/SubscriptionInviteCancelRequestParams' import { SubscriptionInviteDeclineRequestParams } from '../../Request/Subscription/SubscriptionInviteDeclineRequestParams' @@ -45,4 +47,10 @@ export class SubscriptionServer implements SubscriptionServerInterface { return response as SubscriptionInviteResponse } + + async confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise { + const response = await this.httpService.post(Paths.v1.confirmAppleIAP, params) + + return response as AppleIAPConfirmResponse + } } diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts index c7c8a8359..48bba2ee8 100644 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts +++ b/packages/api/src/Domain/Server/Subscription/SubscriptionServerInterface.ts @@ -1,3 +1,5 @@ +import { AppleIAPConfirmResponse } from './../../Response/Subscription/AppleIAPConfirmResponse' +import { AppleIAPConfirmRequestParams } from './../../Request/Subscription/AppleIAPConfirmRequestParams' import { SubscriptionInviteAcceptRequestParams } from '../../Request/Subscription/SubscriptionInviteAcceptRequestParams' import { SubscriptionInviteCancelRequestParams } from '../../Request/Subscription/SubscriptionInviteCancelRequestParams' import { SubscriptionInviteDeclineRequestParams } from '../../Request/Subscription/SubscriptionInviteDeclineRequestParams' @@ -15,4 +17,5 @@ export interface SubscriptionServerInterface { declineInvite(params: SubscriptionInviteDeclineRequestParams): Promise cancelInvite(params: SubscriptionInviteCancelRequestParams): Promise listInvites(params: SubscriptionInviteListRequestParams): Promise + confirmAppleIAP(params: AppleIAPConfirmRequestParams): Promise } diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index 8015fea6f..758c2cd32 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -349,6 +349,8 @@ PODS: - React-Core - RNFS (2.20.0): - React-Core + - RNIap (12.4.4): + - React-Core - RNKeychain (8.0.0): - React-Core - RNPrivacySnapshot (1.0.0): @@ -422,6 +424,7 @@ DEPENDENCIES: - "RNCAsyncStorage (from `../node_modules/@react-native-community/async-storage`)" - RNFileViewer (from `../node_modules/react-native-file-viewer`) - RNFS (from `../node_modules/react-native-fs`) + - RNIap (from `../node_modules/react-native-iap`) - RNKeychain (from `../node_modules/react-native-keychain`) - RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`) - RNShare (from `../node_modules/react-native-share`) @@ -518,6 +521,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-file-viewer" RNFS: :path: "../node_modules/react-native-fs" + RNIap: + :path: "../node_modules/react-native-iap" RNKeychain: :path: "../node_modules/react-native-keychain" RNPrivacySnapshot: @@ -578,6 +583,7 @@ SPEC CHECKSUMS: RNCAsyncStorage: b03032fdbdb725bea0bd9e5ec5a7272865ae7398 RNFileViewer: ce7ca3ac370e18554d35d6355cffd7c30437c592 RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 + RNIap: 3bcd6982cf99503339cf9243e4ba70a45ea2cf72 RNKeychain: 4f63aada75ebafd26f4bc2c670199461eab85d94 RNPrivacySnapshot: 8eaf571478a353f2e5184f5c803164f22428b023 RNShare: a5dc3b9c53ddc73e155b8cd9a94c70c91913c43c diff --git a/packages/mobile/ios/StandardNotes.xcodeproj/project.pbxproj b/packages/mobile/ios/StandardNotes.xcodeproj/project.pbxproj index 66de0a356..7f987140c 100644 --- a/packages/mobile/ios/StandardNotes.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/StandardNotes.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 1C2EEB3B45F4EB07AC795C77 /* (null) in Frameworks */ = {isa = PBXBuildFile; }; 33BB1B14071EBE5978EBF3A8 /* libPods-StandardNotes-StandardNotesTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 04FCB5A3A3387CA3CFC82AA3 /* libPods-StandardNotes-StandardNotesTests.a */; }; BC8DEA834BF198E8511F04FF /* libPods-StandardNotesDev.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 51F2D747BE02C2A1BCFEEFD1 /* libPods-StandardNotesDev.a */; }; + CD6592A9291EEFCC00C09DC6 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6592A8291EEFCC00C09DC6 /* StoreKit.framework */; }; CD7D5ECA27800609005FE1BF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CD7D5EC927800608005FE1BF /* LaunchScreen.storyboard */; }; CD7D5ECF278015D2005FE1BF /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; CD7D5ED0278015D2005FE1BF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; @@ -59,6 +60,7 @@ 66417CEB7622E77D89928FCA /* Pods-StandardNotes.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StandardNotes.debug.xcconfig"; path = "Target Support Files/Pods-StandardNotes/Pods-StandardNotes.debug.xcconfig"; sourceTree = ""; }; 948EE90E15EA48C27577820B /* Pods-StandardNotes.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StandardNotes.release.xcconfig"; path = "Target Support Files/Pods-StandardNotes/Pods-StandardNotes.release.xcconfig"; sourceTree = ""; }; A09B7794259DBFABFC4D05CE /* Pods-StandardNotesDev.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StandardNotesDev.debug.xcconfig"; path = "Target Support Files/Pods-StandardNotesDev/Pods-StandardNotesDev.debug.xcconfig"; sourceTree = ""; }; + CD6592A8291EEFCC00C09DC6 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; CD7D5EC8278005B6005FE1BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = StandardNotes/Info.plist; sourceTree = ""; }; CD7D5EC927800608005FE1BF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; CD7D5EDF278015D2005FE1BF /* StandardNotesDev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StandardNotesDev.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -86,6 +88,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CD6592A9291EEFCC00C09DC6 /* StoreKit.framework in Frameworks */, 1C2EEB3B45F4EB07AC795C77 /* (null) in Frameworks */, DD3D1CE428EC1C8BA0C49211 /* libPods-StandardNotes.a in Frameworks */, ); @@ -153,6 +156,7 @@ 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { isa = PBXGroup; children = ( + CD6592A8291EEFCC00C09DC6 /* StoreKit.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 04FCB5A3A3387CA3CFC82AA3 /* libPods-StandardNotes-StandardNotesTests.a */, 51F2D747BE02C2A1BCFEEFD1 /* libPods-StandardNotesDev.a */, diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 0622ff1c9..b956cf589 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -57,6 +57,7 @@ "react-native-fingerprint-scanner": "standardnotes/react-native-fingerprint-scanner#b55d1c0ca627a87a130f758603f12911fbac200f", "react-native-flag-secure-android": "standardnotes/react-native-flag-secure-android#cb08e74583c22a5d912842459b35ebbbb4bcd852", "react-native-fs": "^2.19.0", + "react-native-iap": "^12.4.4", "react-native-keychain": "standardnotes/react-native-keychain#d277d360494cbd02be4accb4a360772a8e0e97b6", "react-native-privacy-snapshot": "standardnotes/react-native-privacy-snapshot#653e904c90fc6f2b578da59138f2bfe5d7f942fe", "react-native-share": "^7.9.0", diff --git a/packages/mobile/src/Lib/Interface.ts b/packages/mobile/src/Lib/Interface.ts index 6e098653b..a8c19bbad 100644 --- a/packages/mobile/src/Lib/Interface.ts +++ b/packages/mobile/src/Lib/Interface.ts @@ -1,5 +1,6 @@ import AsyncStorage from '@react-native-community/async-storage' import SNReactNative from '@standardnotes/react-native-utils' +import { AppleIAPReceipt } from '@standardnotes/services/dist/Domain/Subscription/AppleIAPReceipt' import { ApplicationIdentifier, Environment, @@ -11,6 +12,7 @@ import { RawKeychainValue, removeFromArray, TransferPayload, + AppleIAPProductId, UuidString, } from '@standardnotes/snjs' import { ColorSchemeObserverService } from 'ColorSchemeObserverService' @@ -41,6 +43,7 @@ import Share from 'react-native-share' import { AndroidBackHandlerService } from '../AndroidBackHandlerService' import { AppStateObserverService } from './../AppStateObserverService' import Keychain from './Keychain' +import { PurchaseManager } from '../PurchaseManager' export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID' @@ -99,6 +102,10 @@ export class MobileDevice implements MobileDeviceInterface { private colorSchemeService?: ColorSchemeObserverService, ) {} + purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise { + return PurchaseManager.getInstance().purchase(plan) + } + deinit() { this.stateObserverService?.deinit() ;(this.stateObserverService as unknown) = undefined @@ -108,7 +115,7 @@ export class MobileDevice implements MobileDeviceInterface { ;(this.colorSchemeService as unknown) = undefined } - consoleLog(...args: any[]): void { + consoleLog(...args: unknown[]): void { // eslint-disable-next-line no-console console.log(args) } diff --git a/packages/mobile/src/Lib/Logging.ts b/packages/mobile/src/Lib/Logging.ts new file mode 100644 index 000000000..d60044537 --- /dev/null +++ b/packages/mobile/src/Lib/Logging.ts @@ -0,0 +1,18 @@ +import { log as utilsLog } from '@standardnotes/snjs' + +export enum LoggingDomain { + AppleIAP, +} + +const LoggingStatus: Record = { + [LoggingDomain.AppleIAP]: true, +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function log(domain: LoggingDomain, ...args: any[]): void { + if (!LoggingStatus[domain]) { + return + } + + utilsLog(LoggingDomain[domain], ...args) +} diff --git a/packages/mobile/src/PurchaseManager.ts b/packages/mobile/src/PurchaseManager.ts new file mode 100644 index 000000000..390b35f29 --- /dev/null +++ b/packages/mobile/src/PurchaseManager.ts @@ -0,0 +1,80 @@ +import { LoggingDomain, log } from './Lib/Logging' +import { EmitterSubscription } from 'react-native' +import { + initConnection, + endConnection, + purchaseErrorListener, + purchaseUpdatedListener, + type ProductPurchase, + type PurchaseError, + type SubscriptionPurchase, + finishTransaction, + requestSubscription, + getSubscriptions, +} from 'react-native-iap' +import { AppleIAPReceipt, AppleIAPProductId } from '@standardnotes/snjs' + +export class PurchaseManager { + private static instance: PurchaseManager + private listenerDisposer: EmitterSubscription + private errorDisposer: EmitterSubscription + + private constructor() { + this.listenerDisposer = purchaseUpdatedListener((purchase: SubscriptionPurchase | ProductPurchase) => { + log(LoggingDomain.AppleIAP, 'purchaseUpdatedListener', purchase) + const receipt = purchase.transactionReceipt + if (receipt) { + void finishTransaction({ purchase, isConsumable: false }) + } + }) + + this.errorDisposer = purchaseErrorListener((error: PurchaseError) => { + log(LoggingDomain.AppleIAP, 'purchaseErrorListener', error) + }) + } + + public static getInstance(): PurchaseManager { + if (!PurchaseManager.instance) { + PurchaseManager.instance = new PurchaseManager() + } + + return PurchaseManager.instance + } + + deinit() { + this.listenerDisposer.remove() + this.errorDisposer.remove() + void endConnection() + } + + async purchase(sku: AppleIAPProductId): Promise { + await initConnection() + + const subscriptions = await getSubscriptions({ + skus: [AppleIAPProductId.PlusPlanYearly, AppleIAPProductId.ProPlanYearly], + }) + + log(LoggingDomain.AppleIAP, 'Retrieved subscriptions', subscriptions) + + try { + const result = await requestSubscription({ sku, andDangerouslyFinishTransactionAutomaticallyIOS: true }) + + log(LoggingDomain.AppleIAP, 'Purchase result', result) + + if (result && result.transactionId && result.transactionDate) { + return { + transactionId: result.transactionId, + productId: result.productId as AppleIAPProductId, + transactionDate: String(result.transactionDate), + transactionReceipt: result.transactionReceipt, + } + } else { + log(LoggingDomain.AppleIAP, 'Purchase method returning undefined even though successful') + return undefined + } + } catch (error) { + log(LoggingDomain.AppleIAP, error) + return undefined + } + } +} diff --git a/packages/services/src/Domain/Device/MobileDeviceInterface.ts b/packages/services/src/Domain/Device/MobileDeviceInterface.ts index 36af893fc..d46efc75e 100644 --- a/packages/services/src/Domain/Device/MobileDeviceInterface.ts +++ b/packages/services/src/Domain/Device/MobileDeviceInterface.ts @@ -1,5 +1,7 @@ +import { AppleIAPProductId } from './../Subscription/AppleIAPProductId' import { DeviceInterface } from './DeviceInterface' import { Environment, Platform, RawKeychainValue } from '@standardnotes/models' +import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt' export interface MobileDeviceInterface extends DeviceInterface { environment: Environment.Mobile @@ -22,4 +24,5 @@ export interface MobileDeviceInterface extends DeviceInterface { isUrlComponentUrl(url: string): boolean getAppState(): Promise<'active' | 'background' | 'inactive' | 'unknown' | 'extension'> getColorScheme(): Promise<'light' | 'dark' | null | undefined> + purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise } diff --git a/packages/services/src/Domain/Event/ApplicationEvent.ts b/packages/services/src/Domain/Event/ApplicationEvent.ts index 250b76806..df4d8d7b3 100644 --- a/packages/services/src/Domain/Event/ApplicationEvent.ts +++ b/packages/services/src/Domain/Event/ApplicationEvent.ts @@ -64,4 +64,5 @@ export enum ApplicationEvent { CompletedInitialSync = 30, BiometricsSoftLockEngaged = 31, BiometricsSoftLockDisengaged = 32, + DidPurchaseSubscription = 33, } diff --git a/packages/services/src/Domain/Feature/FeaturesEvent.ts b/packages/services/src/Domain/Feature/FeaturesEvent.ts index 75595699a..3c536a02b 100644 --- a/packages/services/src/Domain/Feature/FeaturesEvent.ts +++ b/packages/services/src/Domain/Feature/FeaturesEvent.ts @@ -1,4 +1,5 @@ export enum FeaturesEvent { UserRolesChanged = 'UserRolesChanged', FeaturesUpdated = 'FeaturesUpdated', + DidPurchaseSubscription = 'DidPurchaseSubscription', } diff --git a/packages/services/src/Domain/Subscription/AppleIAPProductId.ts b/packages/services/src/Domain/Subscription/AppleIAPProductId.ts new file mode 100644 index 000000000..61c9f80c6 --- /dev/null +++ b/packages/services/src/Domain/Subscription/AppleIAPProductId.ts @@ -0,0 +1,4 @@ +export enum AppleIAPProductId { + ProPlanYearly = 'pro_plan_yearly', + PlusPlanYearly = 'plus_plan_yearly', +} diff --git a/packages/services/src/Domain/Subscription/AppleIAPReceipt.ts b/packages/services/src/Domain/Subscription/AppleIAPReceipt.ts new file mode 100644 index 000000000..8e63726f3 --- /dev/null +++ b/packages/services/src/Domain/Subscription/AppleIAPReceipt.ts @@ -0,0 +1,8 @@ +import { AppleIAPProductId } from './AppleIAPProductId' + +export type AppleIAPReceipt = { + productId: AppleIAPProductId + transactionDate: string + transactionId: string + transactionReceipt: string +} diff --git a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts b/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts index ce9df96d8..de20c323c 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionClientInterface.ts @@ -1,9 +1,14 @@ import { Uuid } from '@standardnotes/common' import { Invitation } from '@standardnotes/models' +import { AppleIAPReceipt } from './AppleIAPReceipt' export interface SubscriptionClientInterface { listSubscriptionInvitations(): Promise inviteToSubscription(inviteeEmail: string): Promise cancelInvitation(inviteUuid: Uuid): Promise acceptInvitation(inviteUuid: Uuid): Promise<{ success: true } | { success: false; message: string }> + confirmAppleIAP( + receipt: AppleIAPReceipt, + subscriptionToken: string, + ): Promise<{ success: true } | { success: false; message: string }> } diff --git a/packages/services/src/Domain/Subscription/SubscriptionManager.ts b/packages/services/src/Domain/Subscription/SubscriptionManager.ts index a98bc082b..0c08d213d 100644 --- a/packages/services/src/Domain/Subscription/SubscriptionManager.ts +++ b/packages/services/src/Domain/Subscription/SubscriptionManager.ts @@ -4,6 +4,7 @@ import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface import { AbstractService } from '../Service/AbstractService' import { SubscriptionClientInterface } from './SubscriptionClientInterface' import { Uuid } from '@standardnotes/common' +import { AppleIAPReceipt } from './AppleIAPReceipt' export class SubscriptionManager extends AbstractService implements SubscriptionClientInterface { constructor( @@ -56,4 +57,24 @@ export class SubscriptionManager extends AbstractService implements Subscription return false } } + + async confirmAppleIAP( + params: AppleIAPReceipt, + subscriptionToken: string, + ): Promise<{ success: true } | { success: false; message: string }> { + try { + const result = await this.subscriptionApiService.confirmAppleIAP({ + ...params, + subscription_token: subscriptionToken, + }) + + if (result.data.error) { + return { success: false, message: result.data.error.message } + } + + return result.data + } catch (error) { + return { success: false, message: 'Could not confirm IAP.' } + } + } } diff --git a/packages/services/src/Domain/index.ts b/packages/services/src/Domain/index.ts index f40af8310..baefb8d05 100644 --- a/packages/services/src/Domain/index.ts +++ b/packages/services/src/Domain/index.ts @@ -76,6 +76,8 @@ export * from './Strings/InfoStrings' export * from './Strings/Messages' export * from './Subscription/SubscriptionClientInterface' export * from './Subscription/SubscriptionManager' +export * from './Subscription/AppleIAPProductId' +export * from './Subscription/AppleIAPReceipt' export * from './Sync/SyncMode' export * from './Sync/SyncOptions' export * from './Sync/SyncQueueStrategy' diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index 6d1a03aa1..f95169bb9 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -287,6 +287,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli return this.listedService } + public get alerts(): ExternalServices.AlertService { + return this.alertService + } + public computePrivateUsername(username: string): Promise { return ComputePrivateUsername(this.options.crypto, username) } @@ -367,8 +371,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli await this.handleStage(ExternalServices.ApplicationStage.StorageDecrypted_09) - this.apiService.loadHost() + const host = this.apiService.loadHost() + + this.httpService.setHost(host) + this.webSocketsService.loadWebSocketUrl() + await this.sessionManager.initializeFromDisk() this.settingsService.initializeFromDisk() @@ -594,6 +602,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli public async setCustomHost(host: string): Promise { await this.setHost(host) + this.webSocketsService.setWebSocketUrl(undefined) } @@ -1072,7 +1081,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.createProtocolService() this.diskStorageService.provideEncryptionProvider(this.protocolService) this.createChallengeService() - this.createHttpManager() + this.createLegacyHttpManager() this.createApiService() this.createHttpService() this.createUserServer() @@ -1238,6 +1247,10 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli void this.notifyEvent(ApplicationEvent.FeaturesUpdated) break } + case ExternalServices.FeaturesEvent.DidPurchaseSubscription: { + void this.notifyEvent(ApplicationEvent.DidPurchaseSubscription) + break + } default: { Utils.assertUnreachable(event) } @@ -1385,7 +1398,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.services.push(this.componentManagerService) } - private createHttpManager() { + private createLegacyHttpManager() { this.deprecatedHttpService = new InternalServices.SNHttpService( this.environment, this.options.appVersion, @@ -1399,7 +1412,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.environment, this.options.appVersion, SnjsVersion, - this.options.defaultHost, this.apiService.processMetaObject.bind(this.apiService), ) } diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index 6907bb60f..ecdbb9757 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -110,7 +110,7 @@ export class SNApiService this.invalidSessionObserver = observer } - public loadHost(): void { + public loadHost(): string { const storedValue = this.storageService.getValue(StorageKey.ServerHost) this.host = storedValue || @@ -120,6 +120,8 @@ export class SNApiService _default_sync_server?: string } )._default_sync_server as string) + + return this.host } public async setHost(host: string): Promise { diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts index 364f61c49..ff5a3c30f 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -10,6 +10,7 @@ import { PureCryptoInterface } from '@standardnotes/sncrypto-common' import { convertTimestampToMilliseconds } from '@standardnotes/utils' import { AlertService, + FeaturesEvent, FeatureStatus, InternalEventBusInterface, StorageKey, @@ -203,6 +204,47 @@ describe('featuresService', () => { }) describe('updateRoles()', () => { + it('setRoles should notify event if roles changed', async () => { + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + + const mock = (featuresService['notifyEvent'] = jest.fn()) + + const newRoles = [...roles, RoleName.PlusUser] + await featuresService.setRoles(newRoles) + + expect(mock.mock.calls[0][0]).toEqual(FeaturesEvent.UserRolesChanged) + }) + + it('should notify of subscription purchase', async () => { + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + + const spy = jest.spyOn(featuresService, 'notifyEvent' as never) + + const newRoles = [...roles, RoleName.ProUser] + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + + expect(spy.mock.calls[2][0]).toEqual(FeaturesEvent.DidPurchaseSubscription) + }) + + it('should not notify of subscription purchase on initial roles load after sign in', async () => { + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + featuresService['roles'] = [] + + const spy = jest.spyOn(featuresService, 'notifyEvent' as never) + + const newRoles = [...roles, RoleName.ProUser] + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + + const triggeredEvents = spy.mock.calls.map((call) => call[0]) + expect(triggeredEvents).not.toContain(FeaturesEvent.DidPurchaseSubscription) + }) + it('saves new roles to storage and fetches features if a role has been added', async () => { const newRoles = [...roles, RoleName.PlusUser] @@ -631,7 +673,7 @@ describe('featuresService', () => { await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false) - featuresService.hasOnlineSubscription = jest.fn().mockReturnValue(false) + featuresService.rolesIncludePaidSubscription = jest.fn().mockReturnValue(false) featuresService['completedSuccessfulFeaturesRetrieval'] = true expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts index 07da4f38c..b85f1fb04 100644 --- a/packages/snjs/lib/Services/Features/FeaturesService.ts +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -154,7 +154,7 @@ export class SNFeaturesService if (stage === ApplicationStage.FullSyncCompleted_13) { void this.addDarkTheme() - if (!this.hasOnlineSubscription()) { + if (!this.rolesIncludePaidSubscription()) { const offlineRepo = this.getOfflineRepo() if (offlineRepo) { void this.downloadOfflineFeatures(offlineRepo) @@ -355,8 +355,12 @@ export class SNFeaturesService } public async updateRolesAndFetchFeatures(userUuid: UuidString, roles: RoleName[]): Promise { + const previousRoles = this.roles + const userRolesChanged = this.haveRolesChanged(roles) + const isInitialLoadRolesChange = previousRoles.length === 0 && userRolesChanged + if (!userRolesChanged && !this.needsInitialFeaturesUpdate) { return } @@ -375,13 +379,23 @@ export class SNFeaturesService await this.didDownloadFeatures(features) } } + + if (userRolesChanged && !isInitialLoadRolesChange) { + if (this.rolesIncludePaidSubscription()) { + await this.notifyEvent(FeaturesEvent.DidPurchaseSubscription) + } + } } - private async setRoles(roles: RoleName[]): Promise { + async setRoles(roles: RoleName[]): Promise { + const rolesChanged = !arraysEqual(this.roles, roles) + this.roles = roles - if (!arraysEqual(this.roles, roles)) { + + if (rolesChanged) { void this.notifyEvent(FeaturesEvent.UserRolesChanged) } + this.storageService.setValue(StorageKey.UserRoles, this.roles) } @@ -434,14 +448,13 @@ export class SNFeaturesService return this.features.find((feature) => feature.identifier === featureId) } - hasOnlineSubscription(): boolean { - const roles = this.roles + rolesIncludePaidSubscription(): boolean { const unpaidRoles = [RoleName.CoreUser] - return roles.some((role) => !unpaidRoles.includes(role)) + return this.roles.some((role) => !unpaidRoles.includes(role)) } public hasPaidOnlineOrOfflineSubscription(): boolean { - return this.hasOnlineSubscription() || this.hasOfflineRepo() + return this.rolesIncludePaidSubscription() || this.hasOfflineRepo() } public rolesBySorting(roles: RoleName[]): RoleName[] { diff --git a/packages/styles/src/Alert/Alert.ts b/packages/styles/src/Alert/Alert.ts index fe8a54729..78b9a0d8b 100644 --- a/packages/styles/src/Alert/Alert.ts +++ b/packages/styles/src/Alert/Alert.ts @@ -23,7 +23,7 @@ export class SKAlert { buttonsString() { const genButton = function (buttonDesc: AlertButton, index: number) { return ` - ` @@ -57,8 +57,8 @@ export class SKAlert { buttonsTemplate = '' panelStyle = 'style="padding-bottom: 8px"' } - const titleTemplate = this.title ? `
${this.title}
` : '' - const messageTemplate = this.text ? `

${this.text}

` : '' + const titleTemplate = this.title ? `
${this.title}
` : '' + const messageTemplate = this.text ? `

${this.text}

` : '' const template = `
diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index 2dbb9c3db..7cde51a4f 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -204,10 +204,6 @@ export class WebApplication extends SNApplication implements WebApplicationInter return this.isNativeMobileWeb() && this.platform === Platform.Ios } - get hideSubscriptionMarketing() { - return this.isNativeIOS() - } - mobileDevice(): MobileDeviceInterface { if (!this.isNativeMobileWeb()) { throw Error('Attempting to access device as mobile device on non mobile platform') diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index ccad56b07..5b36f2275 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -199,7 +199,7 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio - +
= ({ 'Create powerful workflows and organizational layouts with per-tag display preferences.'}

- {!application.hideSubscriptionMarketing && ( - - )} +
) diff --git a/packages/web/src/javascripts/Components/Footer/UpgradeNow.tsx b/packages/web/src/javascripts/Components/Footer/UpgradeNow.tsx index e49543dc5..2b6eb1803 100644 --- a/packages/web/src/javascripts/Components/Footer/UpgradeNow.tsx +++ b/packages/web/src/javascripts/Components/Footer/UpgradeNow.tsx @@ -2,7 +2,6 @@ import { WebApplication } from '@/Application/Application' import { FeaturesController } from '@/Controllers/FeaturesController' import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController' import { observer } from 'mobx-react-lite' -import { loadPurchaseFlowUrl } from '../PurchaseFlow/PurchaseFlowFunctions' type Props = { application: WebApplication @@ -14,22 +13,11 @@ const UpgradeNow = ({ application, featuresController, subscriptionContoller }: const shouldShowCTA = !featuresController.hasFolders const hasAccount = subscriptionContoller.hasAccount - if (hasAccount && subscriptionContoller.hideSubscriptionMarketing) { - return null - } - return shouldShowCTA ? (
diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Subscription/NoSubscription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Subscription/NoSubscription.tsx index d38b7bc45..61642f917 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/Subscription/NoSubscription.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/Subscription/NoSubscription.tsx @@ -2,7 +2,6 @@ import { FunctionComponent, useState } from 'react' import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content' import Button from '@/Components/Button/Button' import { WebApplication } from '@/Application/Application' -import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions' type Props = { application: WebApplication @@ -16,9 +15,7 @@ const NoSubscription: FunctionComponent = ({ application }) => { const errorMessage = 'There was an error when attempting to redirect you to the subscription page.' setIsLoadingPurchaseFlow(true) try { - if (!(await loadPurchaseFlowUrl(application))) { - setPurchaseFlowError(errorMessage) - } + application.openPurchaseFlow() } catch (e) { setPurchaseFlowError(errorMessage) } finally { @@ -31,14 +28,12 @@ const NoSubscription: FunctionComponent = ({ application }) => { You don't have a Standard Notes subscription yet. {isLoadingPurchaseFlow && Redirecting you to the subscription page...} {purchaseFlowError && {purchaseFlowError}} - {!application.hideSubscriptionMarketing && ( -
- - {application.hasAccount() && ( -
- )} +
+ + {application.hasAccount() && ( +
) } diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx index 51f15820e..32c656458 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Account/SubscriptionSharing/NoProSubscription.tsx @@ -2,7 +2,6 @@ import { FunctionComponent, useState } from 'react' import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content' import Button from '@/Components/Button/Button' import { WebApplication } from '@/Application/Application' -import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions' type Props = { application: WebApplication @@ -16,9 +15,7 @@ const NoProSubscription: FunctionComponent = ({ application }) => { const errorMessage = 'There was an error when attempting to redirect you to the subscription page.' setIsLoadingPurchaseFlow(true) try { - if (!(await loadPurchaseFlowUrl(application))) { - setPurchaseFlowError(errorMessage) - } + application.openPurchaseFlow() } catch (e) { setPurchaseFlowError(errorMessage) } finally { @@ -35,14 +32,12 @@ const NoProSubscription: FunctionComponent = ({ application }) => { {isLoadingPurchaseFlow && Redirecting you to the subscription page...} {purchaseFlowError && {purchaseFlowError}} - {!application.hideSubscriptionMarketing && ( -
- - {application.hasAccount() && ( -
- )} +
+ + {application.hasAccount() && ( +
) } diff --git a/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeatureModalType.tsx b/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeatureModalType.tsx new file mode 100644 index 000000000..e587b8297 --- /dev/null +++ b/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeatureModalType.tsx @@ -0,0 +1,4 @@ +export enum PremiumFeatureModalType { + UpgradePrompt, + UpgradeSuccess, +} diff --git a/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeaturesModal.tsx b/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeaturesModal.tsx index 8d6d9566f..0b66a7ac9 100644 --- a/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeaturesModal.tsx +++ b/packages/web/src/javascripts/Components/PremiumFeaturesModal/PremiumFeaturesModal.tsx @@ -4,7 +4,7 @@ import Icon from '@/Components/Icon/Icon' import { WebApplication } from '@/Application/Application' import { openSubscriptionDashboard } from '@/Utils/ManageSubscription' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' -import { loadPurchaseFlowUrl } from '../PurchaseFlow/PurchaseFlowFunctions' +import { PremiumFeatureModalType } from './PremiumFeatureModalType' type Props = { application: WebApplication @@ -13,6 +13,7 @@ type Props = { hasAccount: boolean onClose: () => void showModal: boolean + type: PremiumFeatureModalType } const PremiumFeaturesModal: FunctionComponent = ({ @@ -22,6 +23,7 @@ const PremiumFeaturesModal: FunctionComponent = ({ hasAccount, onClose, showModal, + type = PremiumFeatureModalType.UpgradePrompt, }) => { const plansButtonRef = useRef(null) @@ -29,11 +31,58 @@ const PremiumFeaturesModal: FunctionComponent = ({ if (hasSubscription) { void openSubscriptionDashboard(application) } else if (hasAccount) { - void loadPurchaseFlowUrl(application) + void application.openPurchaseFlow() } else if (window.plansUrl) { window.location.assign(window.plansUrl) } - }, [application, hasSubscription, hasAccount]) + onClose() + }, [application, hasSubscription, hasAccount, onClose]) + + const UpgradePrompt = ( + <> + + To take advantage of {featureName} and other advanced features, upgrade + your current plan. + + +
+ +
+ + ) + + const SuccessPrompt = ( + <> + + Enjoy your new powered up experience. + + +
+ +
+ + ) + + const title = + type === PremiumFeatureModalType.UpgradePrompt ? 'Enable Advanced Features' : 'Your purchase was successful!' + + const iconName = type === PremiumFeatureModalType.UpgradePrompt ? PremiumFeatureIconName : '🎉' + const iconClass = + type === PremiumFeatureModalType.UpgradePrompt + ? `h-12 w-12 ${PremiumFeatureIconClass}` + : 'px-7 py-2 h-24 w-24 text-[50px]' return showModal ? ( @@ -53,25 +102,11 @@ const PremiumFeaturesModal: FunctionComponent = ({ className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast" aria-hidden={true} > - +
-
Enable Advanced Features
+
{title}
- - To take advantage of {featureName} and other advanced features, - upgrade your current plan. - - {!application.hideSubscriptionMarketing && ( -
- -
- )} + {type === PremiumFeatureModalType.UpgradePrompt ? UpgradePrompt : SuccessPrompt}
diff --git a/packages/web/src/javascripts/Components/PurchaseFlow/Panes/CreateAccount.tsx b/packages/web/src/javascripts/Components/PurchaseFlow/Panes/CreateAccount.tsx index 09d3d0257..152e8ecc1 100644 --- a/packages/web/src/javascripts/Components/PurchaseFlow/Panes/CreateAccount.tsx +++ b/packages/web/src/javascripts/Components/PurchaseFlow/Panes/CreateAccount.tsx @@ -7,7 +7,6 @@ import { ChangeEventHandler, FunctionComponent, useEffect, useRef, useState } fr import FloatingLabelInput from '@/Components/Input/FloatingLabelInput' import { isEmailValid } from '@/Utils' import { BlueDotIcon, CircleIcon, DiamondIcon, CreateAccountIllustration } from '@standardnotes/icons' -import { loadPurchaseFlowUrl } from '../PurchaseFlowFunctions' type Props = { viewControllerManager: ViewControllerManager @@ -52,10 +51,7 @@ const CreateAccount: FunctionComponent = ({ viewControllerManager, applic } const subscribeWithoutAccount = () => { - loadPurchaseFlowUrl(application).catch((err) => { - console.error(err) - application.alertService.alert(err).catch(console.error) - }) + application.getViewControllerManager().purchaseFlowController.openPurchaseWebpage() } const handleCreateAccount = async () => { @@ -93,13 +89,7 @@ const CreateAccount: FunctionComponent = ({ viewControllerManager, applic await application.register(email, password) viewControllerManager.purchaseFlowController.closePurchaseFlow() - - if (!application.hideSubscriptionMarketing) { - loadPurchaseFlowUrl(application).catch((err) => { - console.error(err) - application.alertService.alert(err).catch(console.error) - }) - } + viewControllerManager.purchaseFlowController.openPurchaseFlow() } catch (err) { console.error(err) application.alertService.alert(err as string).catch(console.error) @@ -170,13 +160,15 @@ const CreateAccount: FunctionComponent = ({ viewControllerManager, applic > Sign in instead - + {!application.isNativeIOS() && ( + + )}