mobile: pricing changes

This commit is contained in:
Ammar Ahmed
2025-07-01 12:13:03 +05:00
committed by Abdullah Atta
parent 5fe29af51a
commit 24da82313e
22 changed files with 2794 additions and 1139 deletions

View File

@@ -19,9 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { Platform, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import {
eSubscribeEvent,
eUnSubscribeEvent
@@ -31,9 +28,7 @@ import { eCloseLoginDialog, eOpenLoginDialog } from "../../utils/events";
import { sleep } from "../../utils/time";
import BaseDialog from "../dialog/base-dialog";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth, initialAuthMode } from "./common";
import { initialAuthMode } from "./common";
import { Login } from "./login";
import { Signup } from "./signup";
import { strings } from "@notesnook/intl";
@@ -51,7 +46,6 @@ const AuthModal = () => {
const [visible, setVisible] = useState(false);
const [currentAuthMode, setCurrentAuthMode] = useState(AuthMode.login);
const actionSheetRef = useRef();
const insets = useGlobalSafeAreaInsets();
useEffect(() => {
eSubscribeEvent(eOpenLoginDialog, open);
@@ -99,81 +93,18 @@ const AuthModal = () => {
centered={false}
enableSheetKeyboardHandler
>
<KeyboardAwareScrollView
style={{
width: "100%"
}}
enableAutomaticScroll={false}
keyboardShouldPersistTaps="handled"
>
{currentAuthMode !== AuthMode.login ? (
<Signup
changeMode={(mode) => setCurrentAuthMode(mode)}
trial={AuthMode.trialSignup === currentAuthMode}
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
/>
) : (
<Login
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
changeMode={(mode) => setCurrentAuthMode(mode)}
/>
)}
</KeyboardAwareScrollView>
<View
style={{
position: "absolute",
paddingTop: Platform.OS === "android" ? 0 : insets.top,
top: 0,
zIndex: 999,
backgroundColor: colors.secondary.background,
width: "100%"
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
width: "100%",
height: 50,
justifyContent:
initialAuthMode.current !== AuthMode.welcomeSignup
? "space-between"
: "flex-end"
}}
>
{initialAuthMode.current === AuthMode.welcomeSignup ? null : (
<IconButton
name="arrow-left"
onPress={() => {
hideAuth();
}}
color={colors.primary.paragraph}
/>
)}
{initialAuthMode.current !== AuthMode.welcomeSignup ? null : (
<Button
title={strings.skip()}
onPress={() => {
hideAuth();
}}
iconSize={16}
type="plain"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
/>
)}
</View>
</View>
{currentAuthMode !== AuthMode.login ? (
<Signup
changeMode={(mode) => setCurrentAuthMode(mode)}
trial={AuthMode.trialSignup === currentAuthMode}
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
/>
) : (
<Login
welcome={initialAuthMode.current === AuthMode.welcomeSignup}
changeMode={(mode) => setCurrentAuthMode(mode)}
/>
)}
<Toast context="local" />
</BaseDialog>

View File

@@ -0,0 +1,80 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { Platform, View } from "react-native";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const insets = useGlobalSafeAreaInsets();
return (
<View
style={{
paddingTop: Platform.OS === "android" ? 0 : insets.top,
backgroundColor: colors.secondary.background,
width: "100%"
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
width: "100%",
height: 50,
justifyContent: !props.welcome ? "space-between" : "flex-end"
}}
>
{props.welcome ? null : (
<IconButton
name="arrow-left"
onPress={() => {
hideAuth();
}}
color={colors.primary.paragraph}
/>
)}
{!props.welcome ? null : (
<Button
title="Skip"
onPress={() => {
hideAuth();
}}
iconSize={16}
type="plain"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: 6
}}
/>
)}
</View>
</View>
);
};

View File

@@ -302,4 +302,4 @@ export const Login = ({ changeMode }) => {
</View>
</>
);
};
};

View File

@@ -0,0 +1,27 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useContext } from "react";
export const SignupContext = React.createContext<{
signup?: () => Promise<boolean>;
}>({
signup: undefined
});
export const useSignupContext = () => useContext(SignupContext);

View File

@@ -21,24 +21,32 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { openLinkInBrowser } from "../../utils/functions";
import { AppFontSize } from "../../utils/size";
import { sleep } from "../../utils/time";
import { AppFontSize, SIZE } from "../../utils/size";
import { Loading } from "../loading";
import { PaywallComponent } from "../premium/component";
import { Button } from "../ui/button";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { DefaultAppStyles } from "../../utils/styles";
import { AuthHeader } from "./header";
import { SignupContext } from "./signup-context";
export const Signup = ({ changeMode, trial }) => {
const SignupSteps = {
signup: 0,
selectPlan: 1,
createAccount: 2
};
export const Signup = ({ changeMode, welcome }) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const email = useRef();
const emailInputRef = useRef();
@@ -52,6 +60,7 @@ export const Signup = ({ changeMode, trial }) => {
const setLastSynced = useUserStore((state) => state.setLastSynced);
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const deviceMode = useSettingStore((state) => state.deviceMode);
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
@@ -70,23 +79,20 @@ export const Signup = ({ changeMode, trial }) => {
const signup = async () => {
if (!validateInfo() || error) return;
if (loading) return;
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(email.current.toLowerCase(), password.current);
let user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
clearMessage();
setEmailVerifyMessage();
hideAuth();
SettingsService.setProperty("encryptedBackup", true);
await sleep(300);
if (trial) {
PremiumService.sheet(null, null, true);
} else {
PremiumService.showVerifyEmailDialog();
}
setCurrentStep(SignupSteps.selectPlan);
return true;
} catch (e) {
setCurrentStep(SignupSteps.signup);
setLoading(false);
ToastManager.show({
heading: strings.signupFailed(),
@@ -94,214 +100,265 @@ export const Signup = ({ changeMode, trial }) => {
type: "error",
context: "local"
});
return false;
}
};
return (
<>
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
height: "100%",
alignSelf: "center"
}}
>
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: DefaultAppStyles.GAP,
backgroundColor: colors.secondary.background,
borderBottomWidth: 0.8,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : null,
borderColor: isTablet ? colors.primary.border : null,
borderRadius: isTablet ? 20 : null,
marginTop: isTablet ? 50 : null,
width: !isTablet ? null : "70%",
minHeight: height * 0.4
}}
>
<View
<SignupContext.Provider
value={{
signup: signup
}}
>
{currentStep === SignupSteps.signup ? (
<>
<AuthHeader welcome={welcome} />
<KeyboardAwareScrollView
style={{
flexDirection: "row"
width: "100%"
}}
contentContainerStyle={{
minHeight: "90%"
}}
nestedScrollEnabled
enableAutomaticScroll={false}
keyboardShouldPersistTaps="handled"
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%"
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
extraBold
style={{
marginBottom: 25,
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
size={AppFontSize.xxl}
>
{strings.createYourAccount()}
</Heading>
</View>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
paddingHorizontal: DefaultAppStyles.GAP,
backgroundColor: colors.primary.background,
alignSelf: "center"
}}
>
<Input
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.email()}
placeholder={strings.email()}
onSubmit={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
confirmPasswordInputRef.current?.focus();
}}
/>
<Input
fwdRef={confirmPasswordInputRef}
onChangeText={(value) => {
confirmPassword.current = value;
}}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
validationType="confirmPassword"
customValidator={() => password.current}
placeholder={strings.confirmPassword()}
marginBottom={12}
onSubmit={signup}
/>
<Paragraph
style={{
marginBottom: 25
}}
size={AppFontSize.xxs}
color={colors.secondary.paragraph}
>
{strings.signupAgreement[0]()}
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos", colors);
}}
style={{
textDecorationLine: "underline"
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[1]()}
</Paragraph>{" "}
{strings.signupAgreement[2]()}
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy", colors);
}}
style={{
textDecorationLine: "underline"
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[3]()}
</Paragraph>{" "}
{strings.signupAgreement[4]()}
</Paragraph>
<Button
title={!loading ? strings.continue() : null}
type="accent"
loading={loading}
onPress={signup}
style={{
width: 250
}}
/>
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(0);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.alreadyHaveAccount()}{" "}
<Paragraph
size={AppFontSize.xs}
style={{ color: colors.primary.accent }}
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: 16,
backgroundColor: colors.secondary.background,
marginBottom: 20,
borderBottomWidth: 0.8,
borderBottomColor: colors.primary.border,
alignSelf: deviceMode !== "mobile" ? "center" : undefined,
borderWidth: deviceMode !== "mobile" ? 1 : null,
borderColor:
deviceMode !== "mobile" ? colors.primary.border : null,
borderRadius: deviceMode !== "mobile" ? 20 : null,
marginTop: deviceMode !== "mobile" ? 50 : null,
width: deviceMode === "mobile" ? null : "50%",
minHeight: height * 0.25
}}
>
{strings.login()}
<View
style={{
flexDirection: "row"
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
extraBold
style={{
marginBottom: 25,
marginTop: 10
}}
size={SIZE.xxl}
>
{strings.createAccount()}
</Heading>
</View>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
paddingHorizontal: 16,
backgroundColor: colors.primary.background,
flexGrow: 1
}}
>
<Input
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
blurOnSubmit={false}
onSubmit={() => {
if (!email.current) return;
passwordInputRef.current?.focus();
}}
/>
<Input
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
blurOnSubmit={false}
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
if (!password.current) return;
confirmPasswordInputRef.current?.focus();
}}
/>
<Input
fwdRef={confirmPasswordInputRef}
onChangeText={(value) => {
confirmPassword.current = value;
}}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
validationType="confirmPassword"
customValidator={() => password.current}
placeholder={strings.confirmPassword()}
marginBottom={12}
onSubmit={signup}
/>
<Button
title={!loading ? "Continue" : null}
type="accent"
loading={loading}
onPress={() => {
signup();
}}
fontSize={SIZE.md}
width="100%"
/>
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(0);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: 12,
paddingVertical: 12
}}
>
<Paragraph
size={SIZE.xs + 1}
color={colors.secondary.paragraph}
>
{strings.alreadyHaveAccount()}{" "}
<Paragraph
size={SIZE.xs + 1}
style={{ color: colors.primary.accent }}
>
{strings.login()}
</Paragraph>
</Paragraph>
</TouchableOpacity>
</View>
<Paragraph
style={{
marginBottom: 25
}}
size={AppFontSize.xxs}
color={colors.secondary.paragraph}
>
{strings.signupAgreement[0]()}
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos", colors);
}}
style={{
textDecorationLine: "underline"
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[1]()}
</Paragraph>{" "}
{strings.signupAgreement[2]()}
<Paragraph
size={AppFontSize.xxs}
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy", colors);
}}
style={{
textDecorationLine: "underline"
}}
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[3]()}
</Paragraph>{" "}
{strings.signupAgreement[4]()}
</Paragraph>
</Paragraph>
</TouchableOpacity>
</View>
</View>
</>
</View>
</KeyboardAwareScrollView>
</>
) : currentStep === SignupSteps.createAccount ? (
<>
<Loading
title={"Setting up your account..."}
description="Your account is almost ready, please wait..."
/>
</>
) : (
<>
<PaywallComponent
close={() => {
hideAuth();
}}
setupAccount={() => {
setCurrentStep(SignupSteps.createAccount);
}}
isModal={false}
/>
</>
)}
</SignupContext.Provider>
);
};

View File

@@ -18,8 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useRef, useState } from "react";
import { TextInput } from "react-native";
import { db } from "../../common/database";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { ToastManager, eSendEvent } from "../../services/event-manager";
import { clearMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
@@ -34,15 +35,18 @@ export const LoginSteps = {
passwordAuth: 3
};
export const useLogin = (onFinishLogin, sessionExpired = false) => {
export const useLogin = (
onFinishLogin?: () => void,
sessionExpired = false
) => {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const [step, setStep] = useState(LoginSteps.emailAuth);
const email = useRef();
const password = useRef();
const emailInputRef = useRef();
const passwordInputRef = useRef();
const email = useRef<string>();
const password = useRef<string>();
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const validateInfo = () => {
if (
@@ -69,11 +73,15 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (!email.current) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(email.current);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa, callback) => {
async (mfa: any, callback: (success: boolean) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
@@ -111,10 +119,14 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
break;
}
case LoginSteps.passwordAuth: {
if (!email.current || !password.current) {
setLoading(false);
return;
}
await db.user.authenticatePassword(
email.current,
password.current,
null,
undefined,
sessionExpired
);
finishLogin();
@@ -123,11 +135,11 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
}
setLoading(false);
} catch (e) {
finishWithError(e);
finishWithError(e as Error);
}
};
const finishWithError = async (e) => {
const finishWithError = async (e: Error) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
ToastManager.show({
@@ -146,7 +158,7 @@ export const useLogin = (onFinishLogin, sessionExpired = false) => {
clearMessage();
ToastManager.show({
heading: strings.loginSuccess(),
message: strings.loginSuccessDesc(),
message: strings.loginSuccessDesc(user.email),
type: "success",
context: "global"
});

View File

@@ -24,13 +24,13 @@ import { Linking, ScrollView, useWindowDimensions, View } from "react-native";
import { SwiperFlatList } from "react-native-swiper-flatlist";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import Navigation from "../../services/navigation";
import SettingsService from "../../services/settings";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { AuthMode } from "../auth/common";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import SettingsService from "../../services/settings";
import { AuthMode } from "../auth/common";
import { DefaultAppStyles } from "../../utils/styles";
const Intro = () => {
const { colors } = useThemeColors();
@@ -182,6 +182,17 @@ const Intro = () => {
type="accent"
title={strings.getStarted()}
/>
<Button
width="100%"
title={"I already have an account"}
type="secondary"
onPress={() => {
SettingsService.set({
introCompleted: true
});
}}
/>
</View>
</ScrollView>
);

View File

@@ -0,0 +1,88 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { ProgressBarComponent } from "../ui/svg/lazy";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
export const Loading = (props: {
title?: string;
description?: string;
icon?: string;
}) => {
const { colors } = useThemeColors();
return (
<View
style={{
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 16
}}
>
{props.icon ? (
<Icon name={props.icon} size={80} color={colors.primary.accent} />
) : null}
{props.title ? (
<Heading
style={{
textAlign: "center"
}}
>
{props.title}
</Heading>
) : null}
{props.description ? (
<Paragraph
style={{
textAlign: "center"
}}
>
{props.description}
</Paragraph>
) : null}
<View
style={{
flexDirection: "row",
width: 100,
marginTop: 15
}}
>
<ProgressBarComponent
height={5}
width={100}
animated={true}
useNativeDriver
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
</View>
);
};

View File

@@ -1,290 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { ActivityIndicator, Platform, ScrollView, View } from "react-native";
import { LAUNCH_ROCKET } from "../../assets/images/assets";
import { db } from "../../common/database";
import { usePricing } from "../../hooks/use-pricing";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { getElevationStyle } from "../../utils/elevation";
import { eClosePremiumDialog, eCloseSheet } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { sleep } from "../../utils/time";
import { AuthMode } from "../auth/common";
import SheetProvider from "../sheet-provider";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Seperator from "../ui/seperator";
import { SvgView } from "../ui/svg";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { Walkthrough } from "../walkthroughs";
import { features } from "./features";
import { Group } from "./group";
import { PricingPlans } from "./pricing-plans";
import { DefaultAppStyles } from "../../utils/styles";
export const Component = ({ close, promo }) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const userCanRequestTrial =
user && (!user.subscription || !user.subscription.expiry) ? true : false;
const [floatingButton, setFloatingButton] = useState(false);
const pricing = usePricing("monthly");
const onPress = async () => {
if (user) {
presentSheet({
context: "pricing_plans",
component: (
<PricingPlans showTrialOption={false} marginTop={1} promo={promo} />
)
});
} else {
close();
Navigation.navigate("Auth", {
mode: AuthMode.trialSignup
});
}
};
const onScroll = (event) => {
let contentSize = event.nativeEvent.contentSize.height;
contentSize = contentSize - event.nativeEvent.layoutMeasurement.height;
let yOffset = event.nativeEvent.contentOffset.y;
if (yOffset > 600 && yOffset < contentSize - 400) {
setFloatingButton(true);
} else {
setFloatingButton(false);
}
};
return (
<View
style={{
width: "100%",
backgroundColor: colors.primary.background,
justifyContent: "space-between",
borderRadius: 10,
maxHeight: "100%"
}}
>
<SheetProvider context="pricing_plans" />
<IconButton
onPress={() => {
close();
}}
style={{
position: "absolute",
right: DDS.isTab ? 30 : 15,
top: Platform.OS === "ios" ? 0 : 30,
zIndex: 10,
width: 50,
height: 50
}}
color={colors.primary.paragraph}
name="close"
/>
<ScrollView
style={{
paddingHorizontal: DDS.isTab ? DDS.width / 5 : 0
}}
scrollEventThrottle={0}
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
onScroll={onScroll}
>
<View
key="top-banner"
style={{
width: "100%",
alignItems: "center",
height: 400,
justifyContent: "center"
}}
>
<SvgView
width={350}
height={350}
src={LAUNCH_ROCKET(colors.primary.accent)}
/>
</View>
<Heading
key="heading"
size={AppFontSize.lg}
style={{
alignSelf: "center",
paddingTop: 20
}}
>
Notesnook{" "}
<Heading size={AppFontSize.lg} color={colors.primary.accent}>
Pro
</Heading>
</Heading>
{!pricing ? (
<ActivityIndicator
style={{
marginBottom: 20
}}
size={AppFontSize.md}
color={colors.primary.accent}
/>
) : (
<Paragraph
style={{
alignSelf: "center",
marginBottom: 20
}}
size={AppFontSize.md}
>
(
{Platform.OS === "android"
? pricing.product?.subscriptionOfferDetails[0]?.pricingPhases
.pricingPhaseList?.[0].formattedPrice
: pricing.product?.localizedPrice}{" "}
/ mo)
</Paragraph>
)}
<Paragraph
key="description"
size={AppFontSize.md}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
textAlign: "center",
alignSelf: "center",
paddingBottom: 20,
width: "90%"
}}
>
Ready to take the next step on your private note taking journey?
</Paragraph>
{userCanRequestTrial ? (
<Button
key="calltoaction"
onPress={async () => {
try {
await db.user.activateTrial();
eSendEvent(eClosePremiumDialog);
eSendEvent(eCloseSheet);
await sleep(300);
Walkthrough.present("trialstarted", false, true);
} catch (e) {
console.error(e);
}
}}
title="Try free for 14 days"
type="accent"
width={250}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: 15,
borderRadius: 100
}}
/>
) : null}
<Button
key="calltoaction"
onPress={onPress}
title={
promo ? promo.text : user ? "See all plans" : "Sign up for free"
}
type={userCanRequestTrial ? "secondaryAccented" : "accent"}
width={250}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: 15,
borderRadius: 100
}}
/>
{!user || userCanRequestTrial ? (
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.xs}
style={{
alignSelf: "center",
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
maxWidth: "80%"
}}
>
{user
? 'On clicking "Try free for 14 days", your free trial will be activated.'
: "After sign up you will be asked to activate your free trial."}{" "}
<Paragraph size={AppFontSize.xs} style={{ fontWeight: "bold" }}>
No credit card is required.
</Paragraph>
</Paragraph>
) : null}
<Seperator key="seperator_1" />
{features.map((item, index) => (
<Group key={item.title} item={item} index={index} />
))}
<View
key="plans"
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<PricingPlans showTrialOption={false} promo={promo} />
</View>
</ScrollView>
{floatingButton ? (
<Button
onPress={onPress}
title={
promo ? promo.text : user ? "See all plans" : "Sign up for free"
}
type="accent"
style={{
paddingHorizontal: DefaultAppStyles.GAP * 2,
position: "absolute",
borderRadius: 100,
bottom: 30,
...getElevationStyle(10)
}}
/>
) : null}
<Toast context="local" />
<View
style={{
paddingBottom: 10
}}
/>
</View>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,60 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const FeaturesList = {
// Monthly: ["$0/mo", "$1.99/mo", "$5.99/mo", "$9.99/mo"],
// Yearly: ["$0/yr", "$14.99/yr", "$59.99/yr", "$109.99/yr"],
// "5 year": [null, null, "$249.99/5yr", "$449.99/5yr"],
Plans: ["Free", "Essential", "Pro", "Believer"],
Devices: [2, 5, "Unlimited", "Unlimited"],
Storage: ["100MB/mo", "1GB/mo", "10GB/mo", "25GB/mo"],
"File size": ["10MB", "100MB", "1GB", "5GB"],
"File compression": [true, true, "Optional", "Optional"],
"Check list": [true, true, true, true],
"Bullet list": [true, true, true, true],
Quote: [true, true, true, true],
"Note link": [true, true, true, true],
"Task list": [false, true, true, true],
"Outline list": [false, true, true, true],
Callouts: [false, true, true, true],
Colors: [7, 20, "∞", "∞"],
Tags: [50, 500, "∞", "∞"],
Notebooks: [100, 1000, "∞", "∞"],
"Active Reminders": [10, 50, "∞", "∞"],
Shortcuts: [10, "∞", "∞", "∞"],
"Note history": ["Local", "Local", "Synced", "Synced"],
"Security key app lock": [false, false, true, true],
"Pin note in notification": [false, false, true, true],
"Create note from notification drawer": [false, false, true, true],
"Default startup screen": [false, false, true, true],
"Markdown shortcuts": [false, true, true, true],
"Custom toolbar preset": [false, false, true, true],
"Customizable navigation menu": [false, true, true, true],
"Files in monograph": [
"Images",
"Images & files",
"Images & files",
"Images & files"
],
"Note history retention": ["1 month", "∞", "∞", "∞"],
"App lock": ["immediate", "immediate", "any", "any"],
"Password on monograph": ["Default", "Custom", "Custom", "Custom"],
"Voice memos": ["5 minute", "1 hour", "∞", true],
"Notebook publishing": [1, 2, 5, true],
Vaults: [1, 3, "∞", "∞"]
};

View File

@@ -24,7 +24,8 @@ import {
} from "../../services/event-manager";
import { eClosePremiumDialog, eOpenPremiumDialog } from "../../utils/events";
import BaseDialog from "../dialog/base-dialog";
import { Component } from "./component";
import { PaywallComponent } from "./component";
import { IconButton } from "../ui/icon-button";
class PremiumDialog extends React.Component {
constructor(props) {
@@ -74,7 +75,7 @@ class PremiumDialog extends React.Component {
background={this.props.colors.primary.background}
onRequestClose={this.onClose}
>
<Component
<PaywallComponent
getRef={() => this.actionSheetRef}
promo={this.state.promo}
close={this.close}

View File

@@ -17,40 +17,27 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useState } from "react";
import React from "react";
import { ActivityIndicator, Platform, Text, View } from "react-native";
import * as RNIap from "react-native-iap";
import { DatabaseLogger, db } from "../../common/database";
import { usePricing } from "../../hooks/use-pricing";
import {
eSendEvent,
presentSheet,
ToastManager
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import PremiumService from "../../services/premium";
import { useSettingStore } from "../../stores/use-setting-store";
import { useUserStore } from "../../stores/use-user-store";
import usePricingPlans from "../../hooks/use-pricing-plans";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import {
eClosePremiumDialog,
eCloseSheet,
eCloseSimpleDialog
eCloseSimpleDialog,
eOpenLoginDialog
} from "../../utils/events";
import { openLinkInBrowser } from "../../utils/functions";
import { AppFontSize } from "../../utils/size";
import { sleep } from "../../utils/time";
import { AuthMode } from "../auth/common";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
import BaseDialog from "../dialog/base-dialog";
import { presentDialog } from "../dialog/functions";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { Walkthrough } from "../walkthroughs";
import { PricingItem } from "./pricing-item";
import { DefaultAppStyles } from "../../utils/styles";
const UUID_PREFIX = "0bdaea";
const UUID_VERSION = "4";
@@ -95,173 +82,21 @@ export const PricingPlans = ({
compact?: boolean;
}) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const [product, setProduct] = useState<{
type: string;
offerType: "monthly" | "yearly";
data: RNIap.Subscription;
cycleText: string;
info: string;
}>();
const [buying, setBuying] = useState(false);
const [loading, setLoading] = useState(false);
const userCanRequestTrial =
user && (!user.subscription || !user.subscription.expiry) ? true : false;
const [upgrade, setUpgrade] = useState(!userCanRequestTrial);
const yearlyPlan = usePricing("yearly");
const monthlyPlan = usePricing("monthly");
const getSkus = useCallback(async () => {
try {
setLoading(true);
if (promo?.promoCode) {
getPromo(promo?.promoCode);
}
setLoading(false);
} catch (e) {
setLoading(false);
}
}, [promo?.promoCode]);
const getPromo = async (code: string) => {
try {
let skuId: string;
if (code.startsWith("com.streetwriters.notesnook")) {
skuId = code;
} else {
skuId = await db.offers?.getCode(
code.split(":")[0],
Platform.OS as "ios" | "android"
);
}
const products = await PremiumService.getProducts();
const product = products.find((p) => p.productId === skuId);
if (!product) return false;
const isMonthly = product.productId.indexOf(".mo") > -1;
const cycleText = isMonthly
? promoCyclesMonthly[
(Platform.OS === "android"
? (product as RNIap.SubscriptionAndroid)
.subscriptionOfferDetails[0]?.pricingPhases
.pricingPhaseList?.[0].billingCycleCount
: parseInt(
(product as RNIap.SubscriptionIOS)
.introductoryPriceNumberOfPeriodsIOS as string
)) as keyof typeof promoCyclesMonthly
]
: promoCyclesYearly[
(Platform.OS === "android"
? (product as RNIap.SubscriptionAndroid)
.subscriptionOfferDetails[0]?.pricingPhases
.pricingPhaseList?.[0].billingCycleCount
: parseInt(
(product as RNIap.SubscriptionIOS)
.introductoryPriceNumberOfPeriodsIOS as string
)) as keyof typeof promoCyclesYearly
];
setProduct({
type: "promo",
offerType: isMonthly ? "monthly" : "yearly",
data: product,
cycleText: cycleText,
info: `Pay ${isMonthly ? "monthly" : "yearly"}, cancel anytime`
});
return true;
} catch (e) {
return false;
}
};
useEffect(() => {
getSkus();
}, [getSkus]);
const buySubscription = async (product: RNIap.Subscription) => {
if (buying || !product) return;
setBuying(true);
try {
if (!user) {
setBuying(false);
return;
}
useSettingStore.getState().setAppDidEnterBackgroundForAction(true);
const androidOfferToken =
Platform.OS === "android"
? (product as RNIap.SubscriptionAndroid).subscriptionOfferDetails[0]
.offerToken
: null;
DatabaseLogger.info(
`Subscription Requested initiated for user ${toUUID(user.id)}`
);
await RNIap.requestSubscription({
sku: product?.productId,
obfuscatedAccountIdAndroid: user.id,
obfuscatedProfileIdAndroid: user.id,
appAccountToken: toUUID(user.id),
andDangerouslyFinishTransactionAutomaticallyIOS: false,
subscriptionOffers: androidOfferToken
? [
{
offerToken: androidOfferToken,
sku: product?.productId
}
]
: undefined
});
useSettingStore.getState().setAppDidEnterBackgroundForAction(false);
setBuying(false);
eSendEvent(eCloseSheet);
eSendEvent(eClosePremiumDialog);
await sleep(500);
presentSheet({
title: "Thank you for subscribing!",
paragraph:
"Your Notesnook Pro subscription will be activated soon. If your account is not upgraded to Notesnook Pro, your money will be refunded to you. In case of any issues, please reach out to us at support@streetwriters.co",
action: async () => {
eSendEvent(eCloseSheet);
},
icon: "check",
actionText: "Continue"
});
} catch (e) {
setBuying(false);
}
};
function getStandardPrice() {
if (!product) return;
const productType = product.offerType;
if (Platform.OS === "android") {
const pricingPhaseListItem = (product.data as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails[0]?.pricingPhases.pricingPhaseList?.[1];
if (!pricingPhaseListItem) {
const product =
productType === "monthly"
? monthlyPlan?.product
: yearlyPlan?.product;
return (product as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails[0]?.pricingPhases.pricingPhaseList?.[0]
?.formattedPrice;
}
return pricingPhaseListItem?.formattedPrice;
} else {
const productDefault =
productType === "monthly" ? monthlyPlan?.product : yearlyPlan?.product;
return (
(product.data as RNIap.SubscriptionIOS)?.localizedPrice ||
(productDefault as RNIap.SubscriptionIOS)?.localizedPrice
);
}
}
const {
buySubscription,
buying,
getStandardPrice,
product,
setBuying,
loading,
user,
setProduct,
monthlyPlan,
yearlyPlan,
getPromo
} = usePricingPlans({
promoOffer: promo
});
return loading ? (
<View
@@ -281,354 +116,215 @@ export const PricingPlans = ({
}}
>
{buying ? (
<BaseDialog statusBarTranslucent centered>
<BaseDialog visible statusBarTranslucent centered>
<ActivityIndicator size={50} color="white" />
</BaseDialog>
) : null}
{!upgrade ? (
{!user && !product ? (
<>
<Paragraph
style={{
alignSelf: "center"
}}
size={AppFontSize.lg}
>
{(Platform.OS === "android"
? (monthlyPlan?.product as RNIap.SubscriptionAndroid | undefined)
?.subscriptionOfferDetails[0]?.pricingPhases
.pricingPhaseList?.[0]?.formattedPrice
: (monthlyPlan?.product as RNIap.SubscriptionIOS | undefined)
?.localizedPrice) ||
(PremiumService.getMontlySub() as any)?.localizedPrice}
/ mo
</Paragraph>
<Button
onPress={() => {
setUpgrade(true);
}}
title={"Upgrade now"}
type="accent"
width={250}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: DefaultAppStyles.GAP,
marginTop: DefaultAppStyles.GAP,
borderRadius: 100
}}
/>
<Button
onPress={async () => {
try {
await db.user?.activateTrial();
eSendEvent(eClosePremiumDialog);
eSendEvent(eCloseSheet);
await sleep(300);
Walkthrough.present("trialstarted", false, true);
} catch (e) {
console.error(e);
}
}}
title={"Try free for 14 days"}
type="secondaryAccented"
width={250}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: 15
}}
/>
</>
) : (
<>
{product?.type === "promo" ? (
<View
style={{
paddingVertical: 15,
alignItems: "center"
}}
>
{product?.offerType === "monthly" ? (
<PricingItem
product={{
type: "monthly",
data: monthlyPlan?.product,
info: "Pay once a month, cancel anytime."
{heading || (monthlyPlan?.info?.discount || 0) > 0 ? (
<>
{monthlyPlan && (monthlyPlan?.info?.discount || 0) > 0 ? (
<View
style={{
alignSelf: "center",
marginTop: marginTop || 20,
marginBottom: 20
}}
strikethrough={true}
/>
>
<Heading
style={{
textAlign: "center"
}}
color={colors.primary.accent}
>
Get {monthlyPlan?.info?.discount}% off in{" "}
{monthlyPlan?.info?.country}
</Heading>
</View>
) : (
<PricingItem
onPress={() => {
if (!monthlyPlan?.product) return;
buySubscription(monthlyPlan?.product);
}}
product={{
type: "yearly",
data: yearlyPlan?.product,
info: "Pay once a year, cancel anytime."
}}
strikethrough={true}
/>
)}
<Heading
style={{
paddingTop: 15,
fontSize: AppFontSize.lg
}}
>
Special offer for you
</Heading>
<View
style={{
paddingVertical: 20,
paddingBottom: 10
}}
>
<Heading
style={{
alignSelf: "center",
textAlign: "center"
marginTop: marginTop || 20,
marginBottom: 20
}}
size={AppFontSize.xxl}
>
{Platform.OS === "android"
? (product.data as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails[0]?.pricingPhases
.pricingPhaseList?.[0]?.formattedPrice
: (product.data as RNIap.SubscriptionIOS)
?.introductoryPrice ||
(product.data as RNIap.SubscriptionIOS)
?.localizedPrice}{" "}
{product?.cycleText
? `for ${product.cycleText}`
: product?.offerType}
Choose a plan
</Heading>
{product?.cycleText ? (
<Paragraph
style={{
color: colors.secondary.paragraph,
alignSelf: "center",
textAlign: "center"
}}
size={AppFontSize.md}
>
then {getStandardPrice()} {product?.offerType}.
</Paragraph>
) : null}
</View>
</View>
) : null}
{user && !product ? (
<>
{heading || (monthlyPlan?.info?.discount || 0) > 0 ? (
<>
{monthlyPlan && (monthlyPlan?.info?.discount || 0) > 0 ? (
<View
style={{
alignSelf: "center",
marginTop: marginTop || 20,
marginBottom: 20
}}
>
<Heading
style={{
textAlign: "center"
}}
color={colors.primary.accent}
>
Get {monthlyPlan?.info?.discount}% off in{" "}
{monthlyPlan?.info?.country}
</Heading>
</View>
) : (
<Heading
style={{
alignSelf: "center",
marginTop: marginTop || 20,
marginBottom: 20
}}
>
Choose a plan
</Heading>
)}
</>
) : null}
<View
style={{
flexDirection: !compact ? "column" : "row",
flexWrap: "wrap",
justifyContent: "space-around"
}}
>
<PricingItem
onPress={() => {
if (!monthlyPlan?.product) return;
buySubscription(monthlyPlan?.product);
}}
compact={compact}
product={{
type: "monthly",
data: monthlyPlan?.product,
info: "Pay once a month, cancel anytime."
}}
/>
{!compact && (
<View
style={{
height: 1,
marginVertical: 5
}}
/>
)}
<PricingItem
onPress={() => {
if (!yearlyPlan?.product) return;
buySubscription(yearlyPlan?.product);
}}
compact={compact}
product={{
type: "yearly",
data: yearlyPlan?.product,
info: "Pay once a year, cancel anytime."
}}
/>
</View>
{Platform.OS !== "ios" ? (
<Button
height={35}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
onPress={() => {
presentDialog({
context: "local",
input: true,
inputPlaceholder: "Enter code",
positiveText: "Apply",
positivePress: async (value) => {
if (!value) return;
eSendEvent(eCloseSimpleDialog);
setBuying(true);
try {
if (!(await getPromo(value as string)))
throw new Error(strings.errorApplyingPromoCode());
ToastManager.show({
heading: "Discount applied!",
type: "success",
context: "local"
});
setBuying(false);
} catch (e) {
setBuying(false);
ToastManager.show({
heading: "Promo code invalid or expired",
message: (e as Error).message,
type: "error",
context: "local"
});
}
},
title: "Have a promo code?",
paragraph:
"Enter your promo code to get a special discount."
});
}}
title="I have a promo code"
/>
) : (
<View
style={{
height: 15
}}
/>
)}
</>
) : (
<View>
{!user ? (
<>
<Button
onPress={() => {
eSendEvent(eClosePremiumDialog);
eSendEvent(eCloseSheet);
Navigation.navigate("Auth", {
mode: AuthMode.login
});
}}
title={"Sign up for free"}
type="accent"
width={250}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
marginTop: product?.type === "promo" ? 0 : 30,
marginBottom: DefaultAppStyles.GAP_VERTICAL
}}
/>
{Platform.OS !== "ios" &&
promo &&
!promo.promoCode.startsWith("com.streetwriters.notesnook") ? (
<Paragraph
size={AppFontSize.md}
textBreakStrategy="balanced"
style={{
alignSelf: "center",
justifyContent: "center",
textAlign: "center"
}}
>
Use promo code{" "}
<Text
style={{
fontFamily: "OpenSans-SemiBold"
}}
>
{promo.promoCode}
</Text>{" "}
at checkout
</Paragraph>
) : null}
</>
) : (
<>
<Button
onPress={() => {
if (!product?.data) return;
buySubscription(product.data);
}}
height={40}
width="50%"
type="accent"
title="Subscribe now"
/>
) : null}
<Button
onPress={() => {
setProduct(undefined);
}}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
height={30}
fontSize={13}
type="errorShade"
title="Cancel promo code"
/>
</>
)}
</View>
<View
style={{
flexDirection: !compact ? "column" : "row",
flexWrap: "wrap",
justifyContent: "space-around"
}}
>
<PricingItem
onPress={() => {
if (!monthlyPlan?.product) return;
buySubscription(monthlyPlan?.product);
}}
compact={compact}
product={{
type: "monthly",
data: monthlyPlan?.product,
info: "Pay once a month, cancel anytime."
}}
/>
{!compact && (
<View
style={{
height: 1,
marginVertical: 5
}}
/>
)}
<PricingItem
onPress={() => {
if (!yearlyPlan?.product) return;
buySubscription(yearlyPlan?.product);
}}
compact={compact}
product={{
type: "yearly",
data: yearlyPlan?.product,
info: "Pay once a year, cancel anytime."
}}
/>
</View>
{Platform.OS !== "ios" ? (
<Button
height={35}
style={{
marginTop: 10
}}
onPress={() => {
presentDialog({
context: "local",
input: true,
inputPlaceholder: "Enter code",
positiveText: "Apply",
positivePress: async (value) => {
if (!value) return;
eSendEvent(eCloseSimpleDialog);
setBuying(true);
try {
if (!(await getPromo(value as string)))
throw new Error("Error applying promo code");
ToastManager.show({
heading: "Discount applied!",
type: "success",
context: "local"
});
setBuying(false);
} catch (e) {
setBuying(false);
ToastManager.show({
heading: "Promo code invalid or expired",
message: (e as Error).message,
type: "error",
context: "local"
});
}
},
title: "Have a promo code?",
paragraph: "Enter your promo code to get a special discount."
});
}}
title="I have a promo code"
/>
) : (
<View
style={{
height: 15
}}
/>
)}
</>
) : (
<View>
{!user ? (
<>
<Button
onPress={() => {
eSendEvent(eClosePremiumDialog);
eSendEvent(eCloseSheet);
setTimeout(() => {
eSendEvent(eOpenLoginDialog, 1);
}, 400);
}}
title={"Sign up for free"}
type="accent"
width={250}
style={{
paddingHorizontal: 12,
marginTop: product?.type === "promo" ? 0 : 30,
marginBottom: 10
}}
/>
{Platform.OS !== "ios" &&
promo &&
!promo.promoCode.startsWith("com.streetwriters.notesnook") ? (
<Paragraph
size={AppFontSize.md}
textBreakStrategy="balanced"
style={{
alignSelf: "center",
justifyContent: "center",
textAlign: "center"
}}
>
Use promo code{" "}
<Text
style={{
fontFamily: "OpenSans-SemiBold"
}}
>
{promo.promoCode}
</Text>{" "}
at checkout
</Paragraph>
) : null}
</>
) : (
<>
<Button
onPress={() => {
if (!product?.data) return;
buySubscription(product.data);
}}
height={40}
width="50%"
type="accent"
title="Subscribe now"
/>
<Button
onPress={() => {
setProduct(undefined);
}}
style={{
marginTop: 5
}}
height={30}
fontSize={13}
type="errorShade"
title="Cancel promo code"
/>
</>
)}
</View>
)}
{!user || !upgrade ? (
{!user ? (
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.xs}
@@ -648,7 +344,7 @@ export const PricingPlans = ({
</Paragraph>
) : null}
{user && upgrade ? (
{user ? (
<>
{Platform.OS === "ios" ? (
<Paragraph

View File

@@ -49,7 +49,7 @@ export const ProTag = ({ width, size, background }) => {
marginRight: 3
}}
size={size}
color={colors.primary.accent}
color={colors.static.orange}
name="crown"
/>
<Paragraph size={size - 1.5} color={colors.primary.accent}>

View File

@@ -0,0 +1,371 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import {
Platform,
ScrollView,
Text,
TouchableOpacity,
View
} from "react-native";
import usePricingPlans from "../../../hooks/use-pricing-plans";
import { presentSheet } from "../../../services/event-manager";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { SIZE } from "../../../utils/size";
import Paragraph from "../../ui/typography/paragraph";
import Heading from "../../ui/typography/heading";
import { useThemeColors } from "@notesnook/theme";
import * as RNIap from "react-native-iap";
import { Button } from "../../ui/button";
import dayjs from "dayjs";
import { openLinkInBrowser } from "../../../utils/functions";
import { IconButton } from "../../ui/icon-button";
import useGlobalSafeAreaInsets from "../../../hooks/use-global-safe-area-insets";
export const BuyPlan = (props: {
planId: string;
productId: string;
canActivateTrial?: boolean;
goBack: () => void;
goNext: () => void;
}) => {
const { colors } = useThemeColors();
const insets = useGlobalSafeAreaInsets();
const pricingPlans = usePricingPlans({
planId: props.planId,
productId: props.productId,
onBuy: () => {
props.goNext();
}
});
const billingDuration = pricingPlans.getBillingDuration(
pricingPlans.selectedProduct as RNIap.Subscription,
0,
0,
true
);
const is5YearPlanSelected =
pricingPlans.selectedProduct?.productId.includes("5year");
return (
<ScrollView
contentContainerStyle={{
gap: 16,
paddingBottom: 80,
paddingTop: Platform.OS === "android" ? insets.top : 0
}}
keyboardDismissMode="none"
keyboardShouldPersistTaps="always"
stickyHeaderIndices={[0]}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
backgroundColor: colors.primary.background
}}
>
<IconButton
name="chevron-left"
onPress={() => {
props.goBack();
}}
style={{
alignSelf: "flex-start"
}}
/>
</View>
<View
style={{
paddingHorizontal: 16,
gap: 16
}}
>
<Heading
style={{
alignSelf: "center",
marginBottom: 10
}}
>
{props.canActivateTrial
? `Try ${pricingPlans.currentPlan?.name} plan for free`
: `${pricingPlans.currentPlan?.name} plan`}{" "}
</Heading>
{props.canActivateTrial ? (
<View
style={{
gap: 10,
marginBottom: 10
}}
>
{(is5YearPlanSelected
? [
"One time purchase, no auto-renewal",
"Pay once and use for 5 years"
]
: [
`Free ${billingDuration.duration} day trial, cancel any time`,
"Google will remind you before your trial ends"
]
).map((item) => (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 10
}}
key={item}
>
<Icon
color={colors.primary.accent}
size={SIZE.lg}
name="check"
/>
<Paragraph>{item}</Paragraph>
</View>
))}
</View>
) : null}
{[
`notesnook.${props.planId}.yearly`,
`notesnook.${props.planId}.monthly`,
...(props.planId === "essential"
? []
: [`notesnook.${props.planId}.5year`])
].map((item) => (
<ProductItem
key={item}
pricingPlans={pricingPlans}
productId={item}
/>
))}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginTop: 20
}}
>
<Heading color={colors.primary.paragraph} size={SIZE.sm}>
Due today{" "}
{pricingPlans.userCanRequestTrial ? (
<Text
style={{
color: colors.primary.accent
}}
>
({billingDuration.duration} days free)
</Text>
) : null}
</Heading>
<Paragraph color={colors.primary.paragraph}>
{pricingPlans.userCanRequestTrial
? "0.00"
: pricingPlans.getStandardPrice(
pricingPlans.selectedProduct as RNIap.Subscription
)}
</Paragraph>
</View>
{pricingPlans.userCanRequestTrial ? (
<View
style={{
flexDirection: "row",
justifyContent: "space-between"
}}
>
<Paragraph color={colors.secondary.paragraph}>
Due{" "}
{dayjs().add(billingDuration.duration, "day").format("DD MMMM")}
</Paragraph>
<Paragraph color={colors.secondary.paragraph}>
{pricingPlans.getStandardPrice(
pricingPlans.selectedProduct as RNIap.Subscription
)}
</Paragraph>
</View>
) : null}
<Button
width="100%"
type="accent"
loading={pricingPlans.loading}
title={
pricingPlans.selectedProduct?.productId.includes("5year")
? "Purchase"
: pricingPlans?.userCanRequestTrial
? "Subscribe and start free trial"
: "Subscribe"
}
onPress={() => {
const offerToken = pricingPlans.getOfferTokenAndroid(
pricingPlans.selectedProduct as RNIap.SubscriptionAndroid,
0
);
pricingPlans.subscribe(
pricingPlans.selectedProduct as RNIap.Subscription,
offerToken
);
}}
/>
<View>
<Heading
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
size={SIZE.xs}
>
{is5YearPlanSelected
? `This is a one time purchase, no subscription.`
: `Cancel anytime, subscription auto-renews.`}
</Heading>
<Heading
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
size={SIZE.xs}
>
By joining you agree to our{" "}
<Text
style={{
textDecorationLine: "underline"
}}
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy");
}}
>
privacy policy
</Text>{" "}
and{" "}
<Text
style={{
textDecorationLine: "underline"
}}
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos");
}}
>
terms of use
</Text>
.
</Heading>
</View>
</View>
</ScrollView>
);
};
const ProductItem = (props: {
pricingPlans: ReturnType<typeof usePricingPlans>;
productId: string;
}) => {
const { colors } = useThemeColors();
const product =
props.pricingPlans?.currentPlan?.subscriptions?.[props.productId] ||
props.pricingPlans?.currentPlan?.products?.[props.productId];
const isAnnual = product?.productId.includes("yearly");
const isSelected =
product?.productId === props.pricingPlans.selectedProduct?.productId;
return (
<TouchableOpacity
style={{
flexDirection: "row",
gap: 10
}}
activeOpacity={0.9}
onPress={() => {
props.pricingPlans.selectProduct(product?.productId);
}}
>
<Icon
name={isSelected ? "radiobox-marked" : "radiobox-blank"}
color={isSelected ? colors.primary.accent : colors.secondary.icon}
size={SIZE.lg}
/>
<View
style={{
gap: 10
}}
>
<View
style={{
flexDirection: "row",
gap: 10
}}
>
<Heading size={SIZE.md}>
{isAnnual
? "Yearly"
: product?.productId.includes("5year")
? "5 year plan (One time purchase)"
: "Monthly"}
</Heading>
{isAnnual ? (
<View
style={{
backgroundColor: colors.static.red,
borderRadius: 100,
paddingHorizontal: 6,
alignItems: "center",
justifyContent: "center"
}}
>
<Heading color={colors.static.white} size={SIZE.xs}>
Best value -{" "}
{props.pricingPlans.compareProductPrice(
props.pricingPlans.currentPlan?.id as string,
`notesnook.${props.pricingPlans.currentPlan?.id}.yearly`,
`notesnook.${props.pricingPlans.currentPlan?.id}.monthly`
)}
% Off
</Heading>
</View>
) : null}
</View>
<Paragraph>
{product?.productId.includes("5year")
? (product as RNIap.Product).localizedPrice
: props.pricingPlans.getStandardPrice(
product as RNIap.Subscription
)}{" "}
{isAnnual || product?.productId.includes("5year")
? `(${props.pricingPlans.getPrice(
product as RNIap.Subscription,
props.pricingPlans.hasTrialOffer() ? 1 : 0,
isAnnual
)}/month)`
: null}
</Paragraph>
</View>
</TouchableOpacity>
);
};

View File

@@ -0,0 +1,817 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useState } from "react";
import { Platform } from "react-native";
import * as RNIap from "react-native-iap";
import { DatabaseLogger, db } from "../common/database";
import { eSendEvent } from "../services/event-manager";
import PremiumService from "../services/premium";
import { useSettingStore } from "../stores/use-setting-store";
import { useUserStore } from "../stores/use-user-store";
import { eClosePremiumDialog, eCloseSheet } from "../utils/events";
import { sleep } from "../utils/time";
function numberWithCommas(x: string) {
const parts = x.toString().split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}
export type PricingPlan = {
id: string;
name: string;
description: string;
subscriptionSkuList: string[];
subscriptions?: Record<string, RNIap.Subscription | undefined>;
products?: Record<string, RNIap.Product | undefined>;
trialSupported?: boolean;
recommended?: boolean;
productSkuList: string[];
};
const UUID_PREFIX = "0bdaea";
const UUID_VERSION = "4";
const UUID_VARIANT = "a";
function toUUID(str: string) {
return [
UUID_PREFIX + str.substring(0, 2), // 6 digit prefix + first 2 oid digits
str.substring(2, 6), // # next 4 oid digits
UUID_VERSION + str.substring(6, 9), // # 1 digit version(0x4) + next 3 oid digits
UUID_VARIANT + str.substring(9, 12), // # 1 digit variant(0b101) + 1 zero bit + next 3 oid digits
str.substring(12)
].join("-");
}
const pricingPlans: PricingPlan[] = [
{
id: "free",
name: "Free",
description: "Basic features for personal use",
subscriptionSkuList: [],
productSkuList: []
},
{
id: "essential",
name: "Essential",
description: "Unlocks essential features for personal use",
subscriptionSkuList: [
"notesnook.essential.monthly",
"notesnook.essential.yearly",
// no trial
"notesnook.essential.monthly.nt",
"notesnook.essential.yearly.nt"
],
trialSupported: true,
productSkuList: []
},
{
id: "pro",
name: "Pro",
description: "Unlocks all features for professional use",
subscriptionSkuList: [
"notesnook.pro.monthly",
"notesnook.pro.yearly",
"notesnook.pro.monthly.tier2",
"notesnook.pro.yearly.tier2",
"notesnook.pro.monthly.tier3",
"notesnook.pro.yearly.tier3",
// no trial
"notesnook.pro.monthly.nt",
"notesnook.pro.yearly.nt",
"notesnook.pro.monthly.tier2.nt",
"notesnook.pro.yearly.tier2.nt",
"notesnook.pro.monthly.tier3.nt",
"notesnook.pro.yearly.tier3.nt"
],
productSkuList: ["notesnook.pro.5year"],
trialSupported: true,
recommended: true
},
{
id: "believer",
name: "Believer",
description: "Become a believer and support the project",
subscriptionSkuList: [
"notesnook.believer.monthly",
"notesnook.believer.yearly",
// no trial
"notesnook.believer.monthly.nt",
"notesnook.believer.yearly.nt"
],
productSkuList: ["notesnook.believer.5year"],
trialSupported: true
}
];
const promoCyclesMonthly = {
1: "first month",
2: "first 2 months",
3: "first 3 months",
4: "first 4 months",
5: "first 5 months",
6: "first 3 months"
};
const promoCyclesYearly = {
1: "first year",
2: "first 2 years",
3: "first 3 years"
};
type PricingPlansOptions = {
promoOffer?: {
promoCode: string;
};
planId?: string;
productId?: string;
onBuy?: () => void;
};
const usePricingPlans = (options?: PricingPlansOptions) => {
const user = useUserStore((state) => state.user);
const [currentPlan, setCurrentPlan] = useState<string>(
options?.planId || pricingPlans[0].id
);
const [plans, setPlans] = useState<PricingPlan[]>(pricingPlans);
const [loading, setLoading] = useState(false);
const [loadingPlans, setLoadingPlans] = useState(true);
const [selectedProductSku, setSelectedProductSku] = useState<
string | undefined
>(options?.productId || undefined);
const [isPromoOffer, setIsPromoOffer] = useState(false);
const [cancelPromo, setCancelPromo] = useState(false);
const getProduct = (planId: string, skuId: string) => {
return (
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
);
};
const getProductAndroid = (planId: string, skuId: string) => {
return getProduct(planId, skuId) as RNIap.SubscriptionAndroid;
};
const getProductIOS = (planId: string, skuId: string) => {
return getProduct(planId, skuId) as RNIap.SubscriptionIOS;
};
const hasTrialOffer = () => {
if (!selectedProductSku) return false;
return Platform.OS === "ios"
? !!(getProduct(currentPlan, selectedProductSku) as RNIap.SubscriptionIOS)
?.introductoryPrice
: (
getProduct(
currentPlan,
selectedProductSku
) as RNIap.SubscriptionAndroid
).subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList
?.length > 1;
};
const userCanRequestTrial = hasTrialOffer();
// user && (!user.subscription || !user.subscription.expiry) ? true : false;
useEffect(() => {
const loadPlans = async () => {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find((p) => p.productId === sku);
});
});
setPlans([...pricingPlans]);
setLoadingPlans(false);
};
const loadPromoOffer = async () => {
if (cancelPromo) {
setIsPromoOffer(false);
return;
}
if (options?.promoOffer?.promoCode) {
const promoCode = options.promoOffer.promoCode;
let skuId: string;
if (promoCode.startsWith("com.streetwriters.notesnook")) {
skuId = promoCode;
} else {
skuId = await db.offers?.getCode(
promoCode.split(":")[0],
Platform.OS as "ios" | "android" | "web"
);
}
const plan = pricingPlans.find((p) =>
p.subscriptionSkuList.includes(skuId)
);
if (plan) {
setCurrentPlan(plan.id);
setSelectedProductSku(skuId);
setIsPromoOffer(true);
}
}
};
loadPlans().then(() => loadPromoOffer());
}, [options?.promoOffer, cancelPromo]);
function getLocalizedPrice(product: RNIap.Subscription | RNIap.Product) {
if (!product) return;
if (Platform.OS === "android") {
const pricingPhaseListItem = (product as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList?.[1];
return (
pricingPhaseListItem?.formattedPrice ||
(product as RNIap.ProductAndroid).oneTimePurchaseOfferDetails
?.formattedPrice
);
} else {
return (product as RNIap.SubscriptionIOS)?.localizedPrice;
}
}
function getOfferTokenAndroid(
product: RNIap.Subscription,
offerIndex: number
) {
return (product as RNIap.SubscriptionAndroid).subscriptionOfferDetails?.[
offerIndex
].offerToken;
}
async function subscribe(
product: RNIap.Subscription,
androidOfferToken?: string
) {
if (loading || !product) return;
setLoading(true);
try {
if (!user) {
setLoading(false);
return;
}
useSettingStore.getState().setAppDidEnterBackgroundForAction(true);
if (product.productId.includes("5year")) {
if (Platform.OS === "android") {
androidOfferToken =
(
product as RNIap.SubscriptionAndroid
).subscriptionOfferDetails.find(
(offer) => offer.offerToken === androidOfferToken
)?.offerToken ||
(product as RNIap.SubscriptionAndroid).subscriptionOfferDetails?.[0]
.offerToken;
if (!androidOfferToken) return;
}
DatabaseLogger.info(
`Subscription Requested initiated for user ${toUUID(user.id)}`
);
await RNIap.requestSubscription({
sku: product?.productId,
obfuscatedAccountIdAndroid: user.id,
obfuscatedProfileIdAndroid: user.id,
/**
* iOS
*/
appAccountToken: toUUID(user.id),
andDangerouslyFinishTransactionAutomaticallyIOS: false,
subscriptionOffers: androidOfferToken
? [
{
offerToken: androidOfferToken,
sku: product?.productId
}
]
: undefined
});
} else {
await RNIap.requestPurchase({
andDangerouslyFinishTransactionAutomaticallyIOS: false,
appAccountToken: toUUID(user.id),
obfuscatedAccountIdAndroid: user.id,
obfuscatedProfileIdAndroid: user.id,
sku: product.productId,
quantity: 1
});
}
useSettingStore.getState().setAppDidEnterBackgroundForAction(false);
setLoading(false);
options?.onBuy?.();
} catch (e) {
setLoading(false);
}
}
function getPromoCycleText(product: RNIap.Subscription) {
if (!selectedProductSku) return;
const isMonthly = selectedProductSku?.indexOf(".monthly") > -1;
const cycleText = isMonthly
? promoCyclesMonthly[
(Platform.OS === "android"
? (product as RNIap.SubscriptionAndroid).subscriptionOfferDetails[0]
?.pricingPhases.pricingPhaseList?.[0].billingCycleCount
: parseInt(
(product as RNIap.SubscriptionIOS)
.introductoryPriceNumberOfPeriodsIOS as string
)) as keyof typeof promoCyclesMonthly
]
: promoCyclesYearly[
(Platform.OS === "android"
? (product as RNIap.SubscriptionAndroid).subscriptionOfferDetails[0]
?.pricingPhases.pricingPhaseList?.[0].billingCycleCount
: parseInt(
(product as RNIap.SubscriptionIOS)
.introductoryPriceNumberOfPeriodsIOS as string
)) as keyof typeof promoCyclesYearly
];
return cycleText;
}
const getBillingPeriod = (
product: RNIap.Subscription,
offerIndex: number
) => {
if (Platform.OS === "android") {
const period = (product as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[offerIndex]?.pricingPhases
?.pricingPhaseList?.[0].billingPeriod;
return period.endsWith("W")
? "week"
: period.endsWith("M")
? "month"
: "year";
} else {
const unit = (product as RNIap.SubscriptionIOS)
?.subscriptionPeriodUnitIOS;
return unit?.toLocaleLowerCase();
}
};
const getBillingDuration = (
product: RNIap.Subscription,
offerIndex: number,
phaseIndex: number,
trialDurationIos?: boolean
) => {
if (product.productId.includes("5year")) {
return {
type: "year",
duration: 5
};
}
if (Platform.OS === "android") {
const phase = (product as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[offerIndex]?.pricingPhases
?.pricingPhaseList?.[phaseIndex];
const duration = parseInt(phase.billingPeriod[1]);
return {
duration: phase.billingPeriod.endsWith("W") ? duration * 7 : duration,
type: phase.billingPeriod.endsWith("W")
? "week"
: phase.billingPeriod.endsWith("M")
? "month"
: "year"
};
} else {
const productIos = product as RNIap.SubscriptionIOS;
const unit = trialDurationIos
? productIos.introductoryPriceSubscriptionPeriodIOS
: productIos.subscriptionPeriodUnitIOS;
const duration = parseInt(
(trialDurationIos
? productIos.introductoryPriceNumberOfPeriodsIOS
: productIos.subscriptionPeriodNumberIOS) || "1"
);
return {
duration: unit === "WEEK" ? duration * 7 : duration,
type: unit?.toLocaleLowerCase()
};
}
};
const getTrialInfo = (product: RNIap.Subscription) => {
if (Platform.OS === "android") {
const ProductAndroid = (product as RNIap.SubscriptionAndroid)
.subscriptionOfferDetails?.[0];
if (ProductAndroid.pricingPhases.pricingPhaseList?.length === 1) return;
return {
period:
ProductAndroid?.pricingPhases.pricingPhaseList?.[0].billingPeriod,
cycles:
ProductAndroid?.pricingPhases.pricingPhaseList?.[0].billingCycleCount
};
} else {
const productIos = product as RNIap.SubscriptionIOS;
if (!productIos.introductoryPrice) return;
return {
period: productIos.introductoryPriceSubscriptionPeriodIOS,
cycles: productIos.introductoryPriceNumberOfPeriodsIOS
? parseInt(productIos.introductoryPriceNumberOfPeriodsIOS as string)
: 1
};
}
};
const convertPrice = (
amount: number,
symbol: string,
isAtLeft: boolean,
splitBy = 12
) => {
const monthlyPrice = amount / splitBy;
const formattedPrice = numberWithCommas(monthlyPrice.toFixed(2));
return isAtLeft
? `${symbol} ${formattedPrice}`
: `${formattedPrice} ${symbol}`;
};
const getDiscountValue = (p1: string, p2: string, splitToMonth?: boolean) => {
let price1 = Platform.OS === "ios" ? parseInt(p1) : parseInt(p1) / 1000000;
const price2 =
Platform.OS === "ios" ? parseInt(p2) : parseInt(p2) / 1000000;
price1 = splitToMonth ? price1 / 12 : price1;
return (((price2 - price1) / price2) * 100).toFixed(0);
};
const compareProductPrice = (planId: string, sku1: string, sku2: string) => {
const plan = pricingPlans.find((p) => p.id === planId);
const p1 = plan?.subscriptions?.[sku1];
const p2 = plan?.subscriptions?.[sku2];
if (!p1 || !p2) return 0;
if (Platform.OS === "android") {
const androidPricingPhase1 = (p1 as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[0].pricingPhases?.pricingPhaseList?.[1];
const androidPricingPhase2 = (p2 as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[0].pricingPhases?.pricingPhaseList?.[1];
return getDiscountValue(
androidPricingPhase1.priceAmountMicros,
androidPricingPhase2.priceAmountMicros,
true
);
} else {
return getDiscountValue(
(p1 as RNIap.SubscriptionIOS).price,
(p2 as RNIap.SubscriptionIOS).price,
true
);
}
};
const getPriceParts = (price: number, localizedPrice: string) => {
let priceValue: number;
if (Platform.OS === "ios") {
priceValue = price;
} else {
priceValue = price / 1000000;
}
const priceSymbol = localizedPrice.replace(/[\s\d,.]+/, "");
return { priceValue, priceSymbol, localizedPrice };
};
const getPrice = (
product: RNIap.Subscription | RNIap.Product,
phaseIndex: number,
annualBilling?: boolean
) => {
if (!product) return null;
const androidPricingPhase = (product as RNIap.SubscriptionAndroid)
?.subscriptionOfferDetails?.[0].pricingPhases?.pricingPhaseList?.[
phaseIndex
];
console.log(product as RNIap.Product);
const { localizedPrice, priceSymbol, priceValue } = getPriceParts(
Platform.OS === "android"
? parseInt(
androidPricingPhase?.priceAmountMicros ||
(product as RNIap.ProductAndroid)?.oneTimePurchaseOfferDetails
?.priceAmountMicros ||
"0"
)
: parseInt((product as RNIap.SubscriptionIOS).price),
Platform.OS === "android"
? androidPricingPhase?.formattedPrice ||
(product as RNIap.ProductAndroid).oneTimePurchaseOfferDetails
?.formattedPrice ||
"0"
: (product as RNIap.SubscriptionIOS).localizedPrice
);
return !annualBilling && !product?.productId.includes("5year")
? getLocalizedPrice(product as RNIap.Subscription)
: convertPrice(
priceValue,
priceSymbol,
localizedPrice.startsWith(priceSymbol),
annualBilling ? 12 : 60
);
};
// const getPricingPhases = (product: RNIap.Subscription) => {
// if (!product) return null;
// if (Platform.OS === "android") {
// const offer = (product as RNIap.SubscriptionAndroid)
// ?.subscriptionOfferDetails?.[0];
// return offer.pricingPhases?.pricingPhaseList?.map((phase, index) => {
// return {
// localizedPrice: phase.formattedPrice,
// price: parseInt(phase.priceAmountMicros) / 1000000,
// monthlyFormattedPrice: getPrice(product, index, offer?.pricingPhases?.pricingPhaseList?.[1].billingPeriod.endsWith("Y"),
// }
// })
// } else {
// return (product as RNIap.SubscriptionIOS)?.pricingPhases;
// }
// }
return {
currentPlan: pricingPlans.find((p) => p.id === currentPlan),
pricingPlans: plans,
getStandardPrice: getLocalizedPrice,
loadingPlans,
loading,
selectPlan: (planId: string) => {
setCurrentPlan(planId);
setSelectedProductSku(
plans.find((p) => p.id === planId)?.subscriptionSkuList?.[0]
);
setIsPromoOffer(false);
},
convertYearlyPriceToMonthly: convertPrice,
getOfferTokenAndroid,
subscribe,
selectProduct: setSelectedProductSku,
selectedProduct: selectedProductSku
? getProduct(currentPlan, selectedProductSku)
: undefined,
isPromoOffer,
getPromoCycleText,
getProduct,
getProductAndroid,
getProductIOS,
hasTrialOffer,
userCanRequestTrial: userCanRequestTrial,
cancelPromoOffer: () => setCancelPromo(true),
getBillingDuration,
getBillingPeriod,
getTrialInfo,
user,
getPrice,
compareProductPrice,
get5YearPlanProduct: () => {
if (currentPlan === "free" || currentPlan === "essential") return;
return plans.find((p) => p.id === "pro")?.products?.[
`notesnook.${currentPlan}.5year`
];
}
};
};
// const usePricingPlans = (options?: {
// promo?: {
// promoCode?: string;
// };
// }) => {
// const user = useUserStore((state) => state.user);
// const [product, setProduct] = useState<{
// type: string;
// offerType: "monthly" | "yearly";
// data: RNIap.Subscription;
// cycleText: string;
// info: string;
// }>();
// const [buying, setBuying] = useState(false);
// const [loading, setLoading] = useState(false);
// const userCanRequestTrial =
// user && (!user.subscription || !user.subscription.expiry) ? true : false;
// const yearlyPlan = usePricing("yearly");
// const monthlyPlan = usePricing("monthly");
// const promoCode = options?.promo?.promoCode;
// const getSkus = useCallback(async () => {
// try {
// setLoading(true);
// if (promoCode) {
// getPromo(promoCode);
// }
// setLoading(false);
// } catch (e) {
// setLoading(false);
// console.log("error getting sku", e);
// }
// }, [promoCode]);
// const getPromo = async (code: string) => {
// try {
// let skuId: string;
// if (code.startsWith("com.streetwriters.notesnook")) {
// skuId = code;
// } else {
// skuId = await db.offers?.getCode(
// code.split(":")[0],
// Platform.OS as "ios" | "android" | "web"
// );
// }
// const products = await PremiumService.getProducts();
// const product = products.find((p) => p.productId === skuId);
// if (!product) return false;
// const isMonthly = product.productId.indexOf(".mo") > -1;
// const cycleText = isMonthly
// ? promoCyclesMonthly[
// (Platform.OS === "android"
// ? (product as RNIap.SubscriptionAndroid)
// .subscriptionOfferDetails[0]?.pricingPhases
// .pricingPhaseList?.[0].billingCycleCount
// : parseInt(
// (product as RNIap.SubscriptionIOS)
// .introductoryPriceNumberOfPeriodsIOS as string
// )) as keyof typeof promoCyclesMonthly
// ]
// : promoCyclesYearly[
// (Platform.OS === "android"
// ? (product as RNIap.SubscriptionAndroid)
// .subscriptionOfferDetails[0]?.pricingPhases
// .pricingPhaseList?.[0].billingCycleCount
// : parseInt(
// (product as RNIap.SubscriptionIOS)
// .introductoryPriceNumberOfPeriodsIOS as string
// )) as keyof typeof promoCyclesYearly
// ];
// setProduct({
// type: "promo",
// offerType: isMonthly ? "monthly" : "yearly",
// data: product,
// cycleText: cycleText,
// info: `Pay ${isMonthly ? "monthly" : "yearly"}, cancel anytime`
// });
// return true;
// } catch (e) {
// console.log("PROMOCODE ERROR:", code, e);
// return false;
// }
// };
// useEffect(() => {
// getSkus();
// }, [getSkus]);
// const buySubscription = async (product: RNIap.Subscription) => {
// if (buying || !product) return;
// setBuying(true);
// try {
// if (!user) {
// setBuying(false);
// return;
// }
// useSettingStore.getState().setAppDidEnterBackgroundForAction(true);
// const androidOfferToken =
// Platform.OS === "android"
// ? (product as RNIap.SubscriptionAndroid).subscriptionOfferDetails[0]
// .offerToken
// : null;
// DatabaseLogger.info(
// `Subscription Requested initiated for user ${toUUID(user.id)}`
// );
// await RNIap.requestSubscription({
// sku: product?.productId,
// obfuscatedAccountIdAndroid: user.id,
// obfuscatedProfileIdAndroid: user.id,
// appAccountToken: toUUID(user.id),
// andDangerouslyFinishTransactionAutomaticallyIOS: false,
// subscriptionOffers: androidOfferToken
// ? [
// {
// offerToken: androidOfferToken,
// sku: product?.productId
// }
// ]
// : undefined
// });
// useSettingStore.getState().setAppDidEnterBackgroundForAction(false);
// setBuying(false);
// eSendEvent(eCloseSheet);
// eSendEvent(eClosePremiumDialog);
// await sleep(500);
// presentSheet({
// title: "Thank you for subscribing!",
// paragraph:
// "Your Notesnook Pro subscription will be activated soon. If your account is not upgraded to Notesnook Pro, your money will be refunded to you. In case of any issues, please reach out to us at support@streetwriters.co",
// action: async () => {
// eSendEvent(eCloseSheet);
// },
// icon: "check",
// actionText: "Continue"
// });
// } catch (e) {
// setBuying(false);
// console.log(e);
// }
// };
// function getStandardPrice(
// type: "monthly" | "yearly",
// product: RNIap.Subscription
// ) {
// if (!product) return;
// const productType = type;
// if (Platform.OS === "android") {
// const pricingPhaseListItem = (product as RNIap.SubscriptionAndroid)
// ?.subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList?.[1];
// if (!pricingPhaseListItem) {
// const product =
// productType === "monthly"
// ? monthlyPlan?.product
// : yearlyPlan?.product;
// return (product as RNIap.SubscriptionAndroid)
// ?.subscriptionOfferDetails?.[0]?.pricingPhases?.pricingPhaseList?.[0]
// ?.formattedPrice;
// }
// return pricingPhaseListItem?.formattedPrice;
// } else {
// const productDefault =
// productType === "monthly" ? monthlyPlan?.product : yearlyPlan?.product;
// return (
// (product as RNIap.SubscriptionIOS)?.localizedPrice ||
// (productDefault as RNIap.SubscriptionIOS)?.localizedPrice
// );
// }
// }
// return {
// product,
// buySubscription,
// getStandardPrice,
// loading,
// userCanRequestTrial,
// buying,
// setBuying,
// setLoading,
// user,
// setProduct,
// monthlyPlan,
// yearlyPlan,
// getPromo
// };
// };
export default usePricingPlans;

View File

@@ -49,7 +49,9 @@ export const usePricing = (period: "monthly" | "yearly") => {
));
skuInfos[period] = skuInfo;
const products = (await PremiumService.getProducts()) as Subscription[];
const products = (await (
await PremiumService.loadProductsAndSubs()
).subs) as Subscription[];
let product = products.find((p) => p.productId === skuInfo?.sku);
if (!product)
product = products.find((p) => p.productId === getDefaultSku(period));

View File

@@ -44,6 +44,10 @@ let premiumStatus = 0;
/**
* @type {RNIap.Subscription[]}
*/
let subs = [];
/**
* @type {RNIap.Product[]}
*/
let products = [];
let user = null;
@@ -73,7 +77,7 @@ async function setPremiumStatus() {
}
try {
await RNIap.initConnection();
products = await RNIap.getSubscriptions({
subs = await RNIap.getSubscriptions({
skus: itemSkus
});
} catch (e) {}
@@ -83,7 +87,7 @@ async function setPremiumStatus() {
}
function getMontlySub() {
let _product = products.find(
let _product = subs.find(
(p) => p.productId === "com.streetwriters.notesnook.sub.mo"
);
if (!_product) {
@@ -95,11 +99,23 @@ function getMontlySub() {
return _product;
}
async function getProducts() {
if (!products || products.length === 0) {
products = await RNIap.getSubscriptions(itemSkus);
async function loadProductsAndSubs() {
if (!subs || subs.length === 0) {
subs = await RNIap.getSubscriptions({
skus: itemSkus
});
}
return products;
if (!products || products.length === 0) {
products = await RNIap.getProducts({
skus: ["notesnook.pro.5year", "notesnook.believer.5year"]
});
}
return {
subs,
products
};
}
function get() {
@@ -427,7 +443,7 @@ const PremiumService = {
get,
onUserStatusCheck,
showVerifyEmailDialog,
getProducts,
loadProductsAndSubs: loadProductsAndSubs,
getUser,
subscriptions,
getMontlySub,

View File

@@ -31,7 +31,9 @@ import {
import { ParamListBase } from "@react-navigation/core";
import create, { State } from "zustand";
export type GenericRouteParam = undefined;
export type GenericRouteParam = {
canGoBack?: boolean;
};
export type NotebookScreenParams = {
item: Notebook;
@@ -98,7 +100,8 @@ export interface RouteParams extends ParamListBase {
reminder?: Reminder;
reference?: Note;
};
}
Intro: GenericRouteParam;
};
export type RouteName = keyof RouteParams;

View File

@@ -50,19 +50,17 @@ export const SORT = {
};
export const itemSkus = [
"com.streetwriters.notesnook.sub.mo",
"com.streetwriters.notesnook.sub.yr",
"com.streetwriters.notesnook.sub.yr.15",
"com.streetwriters.notesnook.sub.mo.15",
"com.streetwriters.notesnook.sub.mo.ofr",
"com.streetwriters.notesnook.sub.yr.trialoffer",
"com.streetwriters.notesnook.sub.mo.trialoffer",
"com.streetwriters.notesnook.sub.mo.tier1",
"com.streetwriters.notesnook.sub.yr.tier1",
"com.streetwriters.notesnook.sub.mo.tier2",
"com.streetwriters.notesnook.sub.yr.tier2",
"com.streetwriters.notesnook.sub.mo.tier3",
"com.streetwriters.notesnook.sub.yr.tier3"
"notesnook.essential.monthly",
"notesnook.essential.yearly",
"notesnook.pro.monthly",
"notesnook.pro.yearly",
"notesnook.pro.monthly.tier2",
"notesnook.pro.yearly.tier2",
"notesnook.pro.monthly.tier3",
"notesnook.pro.yearly.tier3",
"notesnook.believer.monthly",
"notesnook.believer.yearly",
"notesnook.believer.5year"
];
export const SUBSCRIPTION_STATUS = {

View File

@@ -44449,6 +44449,11 @@
"resolved": "https://registry.npmjs.org/react-native-format-currency/-/react-native-format-currency-0.0.5.tgz",
"integrity": "sha512-iarFkCig987Zw8GFPv0+ltEdAsksf0gfZGWL3+tC+60VHtnfbtFBFWP4523ltuXwplDxKu4Veem1ThEr+pZlvQ=="
},
"node_modules/react-native-format-currency": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/react-native-format-currency/-/react-native-format-currency-0.0.5.tgz",
"integrity": "sha512-iarFkCig987Zw8GFPv0+ltEdAsksf0gfZGWL3+tC+60VHtnfbtFBFWP4523ltuXwplDxKu4Veem1ThEr+pZlvQ=="
},
"node_modules/react-native-gesture-handler": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.25.0.tgz",

View File

@@ -99,7 +99,9 @@ const EXTRA_ICON_NAMES = [
"brain",
"file-tree-outline",
"format-list-bulleted",
"file-tree"
"file-tree",
"github",
"open-source-initiative"
];
const __filename = fileURLToPath(import.meta.url);