web: fix subscription settings for legacy pro users

This commit is contained in:
Abdullah Atta
2025-09-03 14:21:29 +05:00
parent 288f601651
commit c920c999eb
16 changed files with 341 additions and 718 deletions

View File

@@ -34,10 +34,6 @@ import { db } from "./common/db";
import { EV, EVENTS } from "@notesnook/core";
import { registerKeyMap } from "./common/key-map";
import { updateStatus, removeStatus, getStatus } from "./hooks/use-status";
import {
interruptedOnboarding,
OnboardingDialog
} from "./dialogs/onboarding-dialog";
import { hashNavigate } from "./navigation";
import { desktop } from "./common/desktop-bridge";
import { FeatureDialog } from "./dialogs/feature-dialog";
@@ -69,8 +65,6 @@ export default function AppEffects() {
await resetNotices();
setIsVaultCreated(await db.vault.exists());
const onboardingKey = interruptedOnboarding();
if (onboardingKey) await OnboardingDialog.show({ type: onboardingKey });
await FeatureDialog.show({ featureName: "highlights" });
await scheduleBackups();
await scheduleFullBackups();

View File

@@ -37,7 +37,6 @@ import {
} from "./plan-list";
import { useCheckoutStore } from "./store";
import { getCurrencySymbol, toPricingInfo } from "./helpers";
import { isMacStoreApp } from "../../utils/platform";
import { isUserSubscribed } from "../../hooks/use-is-user-premium";
import { SUBSCRIPTION_STATUS } from "../../common/constants";
import BaseDialog from "../../components/dialog";
@@ -126,7 +125,7 @@ export const BuyDialog = DialogManager.register(function BuyDialog(
/>
</ScopedThemeProvider>
<CheckoutDetails
onComplete={onCheckoutComplete ?? (() => {})}
onComplete={onCheckoutComplete ?? (() => onClose(false))}
user={user}
/>
</Flex>

View File

@@ -30,6 +30,7 @@ import { ConfirmDialog } from "./confirm";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
import { getDeviceInfo } from "../utils/platform";
import { getSubscriptionInfo } from "./settings/components/user-profile";
const PLACEHOLDERS = {
title: strings.issueTitlePlaceholder(),
@@ -141,7 +142,7 @@ export const IssueDialog = DialogManager.register(function IssueDialog(
/
</Text>
<Text variant="subBody" mt={1}>
{getDeviceInfo([`Pro: ${isUserPremium()}`])
{getDeviceInfo([`Plan: ${getSubscriptionInfo().title}`])
.split("\n")
.map((t) => (
<>
@@ -168,7 +169,7 @@ function showIssueReportedDialog({ url }: { url: string }) {
const BODY_TEMPLATE = (body: string) => {
const info = `**Device information:**\n${getDeviceInfo([
`Pro: ${isUserPremium()}`
`Plan: ${getSubscriptionInfo().title}`
])}`;
if (!body) return info;
return `${body}\n\n${info}`;

View File

@@ -17,184 +17,21 @@ 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 { Text, Flex, Button, Image, Box, Link } from "@theme-ui/components";
import { Text, Flex, Button } from "@theme-ui/components";
import Dialog from "../components/dialog";
import {
Pro,
Email,
Discord,
Twitter,
Github,
Loading
} from "../components/icons";
import E2E from "../assets/e2e.svg?url";
import Note from "../assets/note2.svg?url";
import Nomad from "../assets/nomad.svg?url";
import WorkAnywhere from "../assets/workanywhere.svg?url";
import Friends from "../assets/cause.svg?url";
import LightUI from "../assets/light1.png?url";
import DarkUI from "../assets/dark1.png?url";
import GooglePlay from "../assets/play.png?url";
import AppleStore from "../assets/apple.png?url";
import { useStore as useThemeStore } from "../stores/theme-store";
import { Features } from "../components/announcements/body";
import { TaskManager } from "../common/task-manager";
import { db } from "../common/db";
import { usePersistentState } from "../hooks/use-persistent-state";
import { useCallback, useState } from "react";
import Config from "../utils/config";
import { isMacStoreApp } from "../utils/platform";
import { ErrorText } from "../components/error-text";
import { BuyDialog } from "./buy-dialog";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { useStore as useUserStore } from "../stores/user-store";
import { getSubscriptionInfo } from "./settings/components/user-profile";
import { strings } from "@notesnook/intl";
import { SettingsDialog } from "./settings";
type Step = {
title: string;
subtitle: string;
buttonText?: string;
image?: JSX.Element;
component?:
| (() => JSX.Element)
| ((props: { onNext: () => void }) => JSX.Element)
| ((props: { onClose: () => void }) => JSX.Element);
};
const newUserSteps: Step[] = [
{
title: strings.safeEncryptedNotes(),
subtitle: strings.writeWithFreedom(),
buttonText: strings.getStarted(),
image: (
<Image src={Note} style={{ flexShrink: 0, width: 120, height: 120 }} />
)
},
{
title: strings.chooseYourStyle(),
subtitle: strings.changeTheme(),
buttonText: strings.next(),
component: ThemeSelector
},
{
image: (
<Image src={E2E} style={{ flexShrink: 0, width: 180, height: 180 }} />
),
title: strings.crossPlatformEncrypted(),
subtitle: strings.encryptsEverything(),
component: CrossPlatform,
buttonText: strings.next()
},
{
title: strings.joinTheCause(),
subtitle: strings.meetPrivacyMinded(),
component: JoinCause,
image: (
<Image src={Friends} style={{ flexShrink: 0, width: 140, height: 140 }} />
)
},
{
image: <Pro size={60} color="accent" />,
title: strings.notesnookPro(),
subtitle: strings.nextLevelPrivateNoteTaking(),
component: TrialOffer
}
];
const proUserSteps: Step[] = [
{
title: strings.welcomeToNotesnookPro(),
subtitle: strings.thankYouPrivacy(),
buttonText: strings.next(),
image: (
<Image src={Nomad} style={{ flexShrink: 0, width: 120, height: 120 }} />
)
},
{
title: strings.weAreAlwaysListening(),
subtitle: strings.weAreAlwaysListening(),
buttonText: strings.next(),
component: Support
},
{
title: strings.importYourNotes(),
subtitle: strings.importYourNotes(),
component: Importer
}
];
const trialUserSteps: Step[] = [
{
title: strings.congratulations(),
subtitle: strings.trialStarted(),
buttonText: strings.continue(),
image: (
<Image
src={WorkAnywhere}
style={{ flexShrink: 0, width: 160, height: 160 }}
/>
)
}
];
const onboarding = {
new: newUserSteps,
pro: proUserSteps,
trial: trialUserSteps
} as const;
export function interruptedOnboarding(): keyof typeof onboarding | undefined {
for (const key in onboarding) {
const index = Config.get(key, undefined);
if (index === null || index === undefined) continue;
if (
index >= 0 &&
index < onboarding[key as keyof typeof onboarding].length - 1
)
return key as keyof typeof onboarding;
}
}
type OnboardingDialogProps = BaseDialogProps<boolean> & {
type: keyof typeof onboarding;
};
type OnboardingDialogProps = BaseDialogProps<boolean>;
export const OnboardingDialog = DialogManager.register(
function OnboardingDialog({
onClose: _onClose,
type
}: OnboardingDialogProps) {
const [step, setStep] = usePersistentState(type, 0);
console.log("STEPS", type);
const steps = onboarding[type];
const onClose = useCallback(
(result: boolean) => {
Config.set(type, steps.length);
_onClose(result);
},
[_onClose, type, steps]
);
const onNext = useCallback(() => {
if (step === steps.length - 1) onClose(true);
else setStep((s) => ++s);
}, [onClose, setStep, step, steps.length]);
if (!steps || !steps[step] || !type) {
onClose(false);
return null;
}
const {
title,
subtitle,
image,
component: Component,
buttonText
} = steps[step];
function OnboardingDialog({ onClose }: OnboardingDialogProps) {
const user = useUserStore((store) => store.user);
const { title } = getSubscriptionInfo(user);
return (
<Dialog isOpen={true} width={500}>
<Dialog isOpen={true} width={500} onClose={() => onClose(false)}>
<Flex
sx={{
flexDirection: "column",
@@ -202,9 +39,8 @@ export const OnboardingDialog = DialogManager.register(
overflowY: "auto"
}}
>
{image}
<Text variant={"heading"} mt={2}>
{title}
{strings.welcomeToPlan(title + " plan")}
</Text>
<Text
variant={"body"}
@@ -214,316 +50,17 @@ export const OnboardingDialog = DialogManager.register(
color: "var(--paragraph-secondary)"
}}
>
{subtitle}
{strings.thankYouPrivacy()}
</Text>
{Component && (
<Component onClose={() => onClose(true)} onNext={onNext} />
)}
{buttonText && (
<Button
variant="accent"
sx={{ borderRadius: 50, px: 30, mb: 4, mt: Component ? 0 : 4 }}
onClick={onNext}
sx={{ borderRadius: 50, px: 30, mb: 4, mt: 4 }}
onClick={() => onClose(false)}
>
{buttonText}
{strings.continue()}
</Button>
)}
</Flex>
</Dialog>
);
}
);
function JoinCause({ onNext }: { onNext: () => void }) {
return (
<Flex mb={4} sx={{ flexDirection: "column" }}>
<Button
as="a"
mt={4}
variant="accent"
sx={{ borderRadius: 50, alignSelf: "center", px: 30 }}
onClick={() => {
window.open("https://go.notesnook.com/discord", "_blank");
onNext();
}}
>
{strings.joinDiscord()}
</Button>
<Button
variant={"anchor"}
mt={2}
onClick={() => onNext()}
sx={{ color: "var(--paragraph-secondary)" }}
>
{strings.skip()}
</Button>
</Flex>
);
}
const importers = [
{ title: "Evernote" },
{ title: "Simplenote" },
{ title: "HTML" },
{ title: "Markdown" },
{ title: "Google Keep" },
{ title: "Standard Notes" }
];
function Importer({ onClose }: { onClose: () => void }) {
return (
<Flex my={4} sx={{ flexDirection: "column" }}>
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 1 }}>
{importers.map((importer) => (
<Flex
key={importer.title}
sx={{
display: "flex",
flexDirection: "row",
borderRadius: "default",
border: "1px solid var(--border)",
alignItems: "center",
justifyContent: "center",
p: 1
}}
>
<Text variant={"body"} ml={1} sx={{ textAlign: "center" }}>
{importer.title}
</Text>
</Flex>
))}
</Box>
<Button
as="a"
mt={4}
variant="accent"
sx={{ borderRadius: 50, alignSelf: "center", px: 30 }}
onClick={() => {
SettingsDialog.show({
activeSection: "importer"
});
onClose();
}}
>
{strings.startImportingNow()}
</Button>
<Button
variant={"anchor"}
mt={2}
onClick={() => onClose()}
sx={{ color: "var(--paragraph-secondary)" }}
>
{strings.skip()}
</Button>
</Flex>
);
}
const supportChannels = [
{
key: "email",
url: "mailto:support@streetwriters.co",
title: strings.emailSupport(),
icon: Email
},
{
key: "discord",
url: "https://discord.com/invite/zQBK97EE22",
title: strings.joinDiscord(),
icon: Discord
},
{
key: "twitter",
url: "https://twitter.com/notesnook",
title: strings.followOnX(),
icon: Twitter
},
{
key: "github",
url: "https://github.com/streetwriters/notesnook",
title: strings.reportAnIssue(),
icon: Github
}
];
function Support() {
return (
<Box my={4} sx={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
{supportChannels.map((channel) => (
<Button
key={channel.key}
as="a"
variant={"icon"}
sx={{
display: "flex",
flexDirection: "row",
borderRadius: "default",
alignItems: "center"
}}
onClick={() => window.open(channel.url)}
>
<channel.icon size={16} />
<Text variant={"body"} ml={1}>
{channel.title}
</Text>
</Button>
))}
</Box>
);
}
const themes = [
{ key: "light" as const, name: "Light", image: LightUI },
{ key: "dark" as const, name: "Dark", image: DarkUI }
];
function ThemeSelector() {
const currentTheme = useThemeStore((store) => store.colorScheme);
const setTheme = useThemeStore((store) => store.setColorScheme);
return (
<Flex
sx={{ border: "1px solid var(--border)", borderRadius: "default" }}
my={4}
>
{themes.map((theme) => {
const isSelected = currentTheme === theme.key;
return (
<Flex
key={theme.key}
p={20}
sx={{
borderRight: "1px solid var(--border)",
bg: isSelected ? "shade" : "transparent",
cursor: "pointer",
":last-of-type": {
borderRight: "0px"
},
":hover": {
bg: "hover"
},
flexDirection: "column",
alignItems: "center",
justifyContent: "center"
}}
onClick={() => {
setTheme(theme.key);
}}
>
<Image
src={theme.image}
sx={{
borderRadius: "default",
border: isSelected ? "2px solid" : "none",
borderColor: "accent",
boxShadow: isSelected ? "0px 0px 10px 1px #00000016" : "none"
}}
/>
<Text variant={"subtitle"} mt={2} sx={{ color: "icon" }}>
{theme.name}
</Text>
</Flex>
);
})}
</Flex>
);
}
function CrossPlatform() {
return (
<Flex my={4} sx={{ alignItems: "center" }}>
{isMacStoreApp() ? null : (
<Link
href="https://play.google.com/store/apps/details?id=com.streetwriters.notesnook"
target="_blank"
rel="noopener noreferrer"
>
<Image src={GooglePlay} sx={{ flexShrink: 0, width: 135 }} />
</Link>
)}
<Link
href="https://apps.apple.com/us/app/notesnook-take-private-notes/id1544027013"
target="_blank"
rel="noopener noreferrer"
>
<Image src={AppleStore} sx={{ flexShrink: 0, width: 110 }} />
</Link>
</Flex>
);
}
function TrialOffer({ onClose }: { onClose: () => void }) {
const [error, setError] = useState<string>();
const [loading, setLoading] = useState<boolean>();
return (
<Flex
my={4}
sx={{
flexDirection: "column",
alignItems: "center",
justifyContent: "center"
}}
>
<Features item={{ style: {} }} />
{error ? (
<ErrorText error={error} />
) : (
<Text
variant={"body"}
mt={2}
bg="var(--background-secondary)"
p={1}
sx={{ borderRadius: "default", color: "var(--paragraph-secondary)" }}
>
<b>Note:</b> Upgrade now and get 30% discount on all plans.
</Text>
)}
<Flex mt={2} sx={{ justifyContent: "center", width: "100%" }}>
<Button
variant="accent"
sx={{ borderRadius: 50, alignSelf: "center", mr: 2, width: "40%" }}
onClick={() => {
onClose();
BuyDialog.show({ plan: "monthly", couponCode: "TRIAL2PRO" });
}}
>
{strings.upgradeToPro()}
</Button>
<Button
variant={"secondary"}
sx={{ borderRadius: 50, alignSelf: "center", width: "40%" }}
onClick={async () => {
try {
setLoading(true);
const result = await TaskManager.startTask({
type: "status",
id: "trialActivation",
title: strings.activatingTrial(),
action: () => db.user.activateTrial()
});
if (result) onClose();
} catch (e) {
setError(
`Could not activate trial. Please try again. Error: ${
(e as Error).message
}`
);
} finally {
setLoading(false);
}
}}
>
{loading ? <Loading size={16} /> : strings.tryFreeFor14Days()}
</Button>
</Flex>
<Button
variant={"anchor"}
mt={2}
onClick={() => onClose()}
sx={{ color: "var(--paragraph-secondary)" }}
>
{strings.skip()}
</Button>
</Flex>
);
}

View File

@@ -20,63 +20,25 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useEffect, useState } from "react";
import { Copy, Loading } from "../../../components/icons";
import { Box, Button, Link, Flex, Text } from "@theme-ui/components";
import { getFormattedDate } from "@notesnook/common";
import { getFormattedDate, usePromise } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { db } from "../../../common/db";
import { TransactionStatus, Transaction } from "@notesnook/core";
import { Transaction, TransactionV1 } from "@notesnook/core";
import { writeToClipboard } from "../../../utils/clipboard";
import { showToast } from "../../../utils/toast";
import { TaskManager } from "../../../common/task-manager";
const TransactionStatusToText: Record<TransactionStatus, string> = {
completed: "Completed",
billed: "Billed",
canceled: "Canceled",
paid: "Paid",
past_due: "Past due"
};
import { ErrorText } from "../../../components/error-text";
export function BillingHistory() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
useEffect(() => {
(async function () {
try {
setError(undefined);
setIsLoading(true);
const transactions = await db.subscriptions.transactions();
if (!transactions) return;
setTransactions(transactions.filter((c) => !!c.billed_at));
} catch (e) {
if (e instanceof Error) setError(e);
} finally {
setIsLoading(false);
}
})();
}, []);
const transactions = usePromise(() => db.subscriptions.transactions(), []);
return (
<>
{isLoading ? (
{transactions.status === "pending" ? (
<Loading sx={{ mt: 2 }} />
) : error ? (
<Flex
sx={{
bg: "var(--background-error)",
p: 1,
borderRadius: "default"
}}
>
<Text variant="error" sx={{ whiteSpace: "pre-wrap" }}>
{error.message}
<br />
{error.stack}
</Text>
</Flex>
) : transactions.length === 0 ? (
) : transactions.status === "rejected" ? (
<ErrorText error={transactions.reason} />
) : transactions.value?.transactions.length === 0 ? (
<Text variant="body" sx={{ mt: 2, color: "paragraph-secondary" }}>
You have not been billed yet.
</Text>
@@ -86,6 +48,79 @@ export function BillingHistory() {
cellPadding={0}
cellSpacing={0}
>
{transactions.value?.type === "v1" ? (
<>
<thead>
<Box
as="tr"
sx={{
height: 30,
th: { borderBottom: "1px solid var(--separator)" }
}}
>
{[
{ id: "date", title: strings.date(), width: "20%" },
{ id: "orderId", title: strings.orderId(), width: "20%" },
{ id: "amount", title: strings.amount(), width: "20%" },
{ id: "status", title: strings.status(), width: "20%" },
{ id: "receipt", title: strings.receipt(), width: "20%" }
].map((column) =>
!column.title ? (
<th key={column.id} />
) : (
<Box
as="th"
key={column.id}
sx={{
width: column.width,
px: 1,
mb: 2,
textAlign: "left"
}}
>
<Text
variant="body"
sx={{ textAlign: "left", fontWeight: "normal" }}
>
{column.title}
</Text>
</Box>
)
)}
</Box>
</thead>
<tbody>
{transactions.value?.transactions.map((transaction) => (
<Box key={transaction.order_id} as="tr" sx={{ height: 30 }}>
<Text as="td" variant="body">
{getFormattedDate(transaction.created_at, "date")}
</Text>
<Text as="td" variant="body">
{transaction.order_id}
</Text>
<Text as="td" variant="body">
{transaction.amount} {transaction.currency}
</Text>
<Text as="td" variant="body">
{strings.transactionStatusToText(transaction.status)}
</Text>
<Text as="td" variant="body">
<Link
href={transaction.receipt_url}
target="_blank"
rel="noreferer nofollow"
variant="text.subBody"
sx={{ color: "accent" }}
>
{strings.viewReceipt()}
</Link>
</Text>
</Box>
))}
</tbody>
</>
) : (
<>
<thead>
<Box
as="tr"
@@ -126,7 +161,7 @@ export function BillingHistory() {
</Box>
</thead>
<tbody>
{transactions.map((transaction) => (
{transactions.value?.transactions.map((transaction) => (
<Box key={transaction.id} as="tr" sx={{ height: 30 }}>
<Text as="td" variant="body">
<Copy
@@ -141,7 +176,9 @@ export function BillingHistory() {
{getFormattedDate(transaction.billed_at, "date")}
</Text>
<Text as="td" variant="body">
{(transaction.details.totals.grand_total / 100).toFixed(2)}{" "}
{(transaction.details.totals.grand_total / 100).toFixed(
2
)}{" "}
{transaction.details.totals.currency_code}
</Text>
<Text as="td" variant="body">
@@ -176,6 +213,8 @@ export function BillingHistory() {
</Box>
))}
</tbody>
</>
)}
</table>
)}
</>

View File

@@ -101,9 +101,11 @@ export function SubscriptionStatus() {
variant="subBody"
>
{feature.id === "storage"
? `${formatBytes(feature.used)}/${formatBytes(
feature.total
)}`
? `${formatBytes(feature.used)}/${
feature.total === Infinity
? "Unlimited"
: formatBytes(feature.total)
}`
: feature.total === Infinity
? "Unlimited"
: `${feature.used} of ${feature.total}`}

View File

@@ -36,7 +36,7 @@ import {
User
} from "@notesnook/core";
export function getSubscriptionInfo(user: User | undefined): {
export function getSubscriptionInfo(user?: User): {
title: string;
trial?: boolean;
paused?: boolean;
@@ -47,6 +47,7 @@ export function getSubscriptionInfo(user: User | undefined): {
autoRenew?: boolean;
trialExpiryDate?: string;
} {
user = user || useUserStore.getState().user;
const { type, expiry, plan, status, provider } = user?.subscription || {};
if (!expiry) return { title: "Free" };
@@ -251,10 +252,17 @@ export function UserProfile({ minimal }: Props) {
</Text>
{user.totalStorage && !minimal ? (
<Flex sx={{ maxWidth: 300, alignItems: "center", gap: 1 }}>
<Progress max={user.totalStorage} value={user.storageUsed || 0} />
<Progress
max={user.totalStorage === -1 ? Infinity : user.totalStorage}
value={user.storageUsed || 0}
color="var(--accent)"
/>
<Text variant="subBody" sx={{ flexShrink: 0 }}>
{formatBytes(user.storageUsed || 0)}/
{formatBytes(user.totalStorage)} used
{user.totalStorage === -1
? "Unlimited"
: formatBytes(user.totalStorage)}{" "}
used
</Text>
</Flex>
) : null}

View File

@@ -36,6 +36,7 @@ import {
import { TaskManager } from "../../common/task-manager";
import { ConfirmDialog } from "../confirm";
import { ChangePlanDialog } from "../buy-dialog/change-plan-dialog";
import { getSubscriptionInfo } from "./components/user-profile";
export const SubscriptionSettings: SettingsGroup[] = [
{
@@ -89,6 +90,7 @@ export const SubscriptionSettings: SettingsGroup[] = [
const user = useUserStore.getState().user;
const status = user?.subscription.status;
return (
getSubscriptionInfo(user).legacy ||
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
!isUserSubscribed(user) ||
status === SubscriptionStatusEnum.CANCELED ||
@@ -136,12 +138,12 @@ export const SubscriptionSettings: SettingsGroup[] = [
title: strings.update(),
action: async () => {
try {
const urls = await db.subscriptions.urls();
if (!urls)
const url = await db.subscriptions.updateUrl();
if (!url)
throw new Error(
"Failed to get subscription management urls. Please contact us at support@streetwriters.co so we can help you update your payment method."
"Failed to get subscription update url. Please contact us at support@streetwriters.co so we can help you update your payment method."
);
window.open(urls?.update_payment_method, "_blank");
window.open(url, "_blank");
} catch (e) {
if (e instanceof Error) showToast("error", e.message);
}
@@ -160,6 +162,7 @@ export const SubscriptionSettings: SettingsGroup[] = [
const user = useUserStore.getState().user;
const status = user?.subscription.status;
return (
getSubscriptionInfo(user).legacy ||
user?.subscription.provider !== SubscriptionProvider.PADDLE ||
!isUserSubscribed(user) ||
status !== SubscriptionStatusEnum.TRIAL

View File

@@ -23,33 +23,10 @@ import {
SubscriptionType,
User
} from "@notesnook/core";
import { SUBSCRIPTION_STATUS } from "../common/constants";
import {
useStore as useUserStore,
store as userstore
} from "../stores/user-store";
export function useIsUserPremium() {
const user = useUserStore((store) => store.user);
return isUserPremium(user);
}
export function isUserPremium(user?: User) {
if (IS_TESTING) return !("isBasic" in window);
if (!user) user = userstore.get().user;
if (!user) return false;
const subStatus = user.subscription.type;
return (
subStatus === SUBSCRIPTION_STATUS.BETA ||
subStatus === SUBSCRIPTION_STATUS.PREMIUM ||
subStatus === SUBSCRIPTION_STATUS.PREMIUM_CANCELED ||
subStatus === SUBSCRIPTION_STATUS.TRIAL
);
}
import { useStore as useUserStore } from "../stores/user-store";
export function isActiveSubscription(user?: User) {
user = user || userstore.get().user;
user = user || useUserStore.getState().user;
if (!user) return false;
const { status } = user?.subscription || {};
@@ -59,7 +36,7 @@ export function isActiveSubscription(user?: User) {
);
}
export function isUserSubscribed(user?: User) {
user = user || userstore.get().user;
user = user || useUserStore.getState().user;
if (!user) return false;
const { type, expiry, plan, status } = user?.subscription || {};

View File

@@ -21,10 +21,9 @@ import createStore from "../common/store";
import { db } from "../common/db";
import BaseStore from "./index";
import Config from "../utils/config";
import { isUserPremium } from "../hooks/use-is-user-premium";
import { SUBSCRIPTION_STATUS } from "../common/constants";
import { isUserSubscribed } from "../hooks/use-is-user-premium";
import { appVersion } from "../utils/version";
import { findItemAndDelete } from "@notesnook/core";
import { findItemAndDelete, SubscriptionStatus } from "@notesnook/core";
/**
* @extends {BaseStore<AnnouncementStore>}
@@ -97,15 +96,12 @@ async function shouldShowAnnouncement(announcement) {
if (!show) return false;
const user = await db.user.getUser();
const subStatus = user?.subscription?.type;
show = announcement.userTypes.some((userType) => {
switch (userType) {
case "pro":
return isUserPremium(user);
case "subscribed":
return isUserSubscribed(user);
case "trial":
return subStatus === SUBSCRIPTION_STATUS.TRIAL;
case "trialExpired":
return subStatus === SUBSCRIPTION_STATUS.BASIC;
return user?.subscription?.status === SubscriptionStatus.TRIAL;
case "loggedOut":
return !user;
case "loggedIn":
@@ -114,8 +110,6 @@ async function shouldShowAnnouncement(announcement) {
return user && !user.isEmailConfirmed;
case "verified":
return user && user.isEmailConfirmed;
case "proExpired":
return subStatus === SUBSCRIPTION_STATUS.PREMIUM_EXPIRED;
case "any":
default:
return true;

View File

@@ -23,12 +23,11 @@ import BaseStore from "./index";
import { EV, EVENTS } from "@notesnook/core";
import Config from "../utils/config";
import { hashNavigate } from "../navigation";
import { isUserPremium } from "../hooks/use-is-user-premium";
import { SUBSCRIPTION_STATUS } from "../common/constants";
import { AuthenticatorType, User } from "@notesnook/core";
import { ConfirmDialog } from "../dialogs/confirm";
import { OnboardingDialog } from "../dialogs/onboarding-dialog";
import { strings } from "@notesnook/intl";
import { isUserSubscribed } from "../hooks/use-is-user-premium";
class UserStore extends BaseStore<UserStore> {
isLoggedIn?: boolean;
@@ -59,16 +58,12 @@ class UserStore extends BaseStore<UserStore> {
if (Config.get("sessionExpired")) return;
EV.subscribe(EVENTS.userSubscriptionUpdated, (subscription) => {
const wasUserPremium = isUserPremium();
const wasSubscribed = isUserSubscribed();
this.set((state) => {
if (!state.user) return;
state.user.subscription = subscription;
});
if (!wasUserPremium && isUserPremium())
OnboardingDialog.show({
type:
subscription.type === SUBSCRIPTION_STATUS.TRIAL ? "trial" : "pro"
});
if (!wasSubscribed && isUserSubscribed()) OnboardingDialog.show({});
});
EV.subscribe(EVENTS.userEmailConfirmed, () => {

View File

@@ -24,7 +24,6 @@ import {
Server,
Clip
} from "@notesnook/web-clipper/dist/common/bridge";
import { isUserPremium } from "../hooks/use-is-user-premium";
import { store as appstore } from "../stores/app-store";
import { h } from "./html";
import { sanitizeFilename } from "@notesnook/common";
@@ -32,6 +31,7 @@ import { getFormattedDate } from "@notesnook/common";
import { useStore as useThemeStore } from "../stores/theme-store";
import { isCipher } from "@notesnook/core";
import { attachFiles } from "../components/editor/picker";
import { isUserSubscribed } from "../hooks/use-is-user-premium";
export class WebExtensionServer implements Server {
async login() {
@@ -39,7 +39,7 @@ export class WebExtensionServer implements Server {
const user = await db.user.getUser();
const theme = colorScheme === "dark" ? darkTheme : lightTheme;
if (!user) return { pro: false, theme };
return { email: user.email, pro: isUserPremium(user), theme };
return { email: user.email, pro: isUserSubscribed(user), theme };
}
async getNotes(): Promise<ItemReference[] | undefined> {

View File

@@ -542,11 +542,9 @@ export type FeatureUsage = {
export async function getFeaturesUsage(): Promise<FeatureUsage[]> {
const { isLegacyPro, plan } = await getUserPlan();
const usage: FeatureUsage[] = [];
console.log(isLegacyPro, plan);
for (const key in features) {
const feature = getFeature(key as FeatureId);
const limit = getFeatureLimitFromPlan(feature, plan, isLegacyPro);
console.log(limit, feature, key);
if (!feature.used || typeof limit.value !== "number") continue;
usage.push({
id: key as FeatureId,

View File

@@ -17,7 +17,12 @@ 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 { planToId, SubscriptionPlan } from "../types.js";
import {
planToId,
SubscriptionPlan,
SubscriptionType,
User
} from "../types.js";
import hosts from "../utils/constants.js";
import http from "../utils/http.js";
import Database from "./index.js";
@@ -30,6 +35,33 @@ export type TransactionStatus =
| "past_due"
| "canceled";
export type TransactionStatusV1 =
| "completed"
| "refunded"
| "partially_refunded"
| "disputed";
export type TransactionV1 = {
order_id: string;
checkout_id: string;
amount: string;
currency: string;
status: TransactionStatusV1;
created_at: string;
passthrough: null;
product_id: number;
is_subscription: boolean;
is_one_off: boolean;
subscription: SubscriptionV1;
user: User;
receipt_url: string;
};
type SubscriptionV1 = {
subscription_id: number;
status: string;
};
export interface Transaction {
id: string;
status: TransactionStatus;
@@ -62,51 +94,76 @@ export default class Subscriptions {
async cancel() {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
await http.post(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/cancel`,
null,
token
);
const user = await this.db.user.getUser();
if (!token || !user) return;
const endpoint = isLegacySubscription(user)
? `subscriptions/cancel`
: `subscriptions/v2/cancel`;
await http.post(`${hosts.SUBSCRIPTIONS_HOST}/${endpoint}`, null, token);
}
async pause() {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
await http.post(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/pause`,
null,
token
);
const user = await this.db.user.getUser();
if (!token || !user) return;
const endpoint = isLegacySubscription(user)
? `subscriptions/cancel?pause=true`
: `subscriptions/v2/pause`;
if (isLegacySubscription(user))
await http.delete(`${hosts.SUBSCRIPTIONS_HOST}/${endpoint}`, token);
else
await http.post(`${hosts.SUBSCRIPTIONS_HOST}/${endpoint}`, null, token);
}
async resume() {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
await http.post(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/resume`,
null,
token
);
const user = await this.db.user.getUser();
if (!token || !user) return;
const endpoint = isLegacySubscription(user)
? `subscriptions/resume`
: `subscriptions/v2/resume`;
await http.post(`${hosts.SUBSCRIPTIONS_HOST}/${endpoint}`, null, token);
}
async refund(reason?: string) {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
const user = await this.db.user.getUser();
if (!token || !user) return;
const endpoint = isLegacySubscription(user)
? `subscriptions/refund`
: `subscriptions/v2/refund`;
await http.post(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/refund`,
`${hosts.SUBSCRIPTIONS_HOST}/${endpoint}`,
{ reason },
token
);
}
async transactions(): Promise<Transaction[] | undefined> {
async transactions(): Promise<
| { type: "v2"; transactions: Transaction[] }
| { type: "v1"; transactions: TransactionV1[] }
| undefined
> {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
return await http.get(
const user = await this.db.user.getUser();
if (!token || !user) return;
if (isLegacySubscription(user)) {
return {
type: "v1",
transactions: await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/transactions`,
token
)
};
} else {
return {
type: "v2",
transactions: await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/transactions`,
token
);
)
};
}
}
async invoice(transactionId: string): Promise<string | undefined> {
@@ -119,15 +176,23 @@ export default class Subscriptions {
return response.url;
}
async urls(): Promise<
{ update_payment_method: string; cancel: string } | undefined
> {
async updateUrl(): Promise<string | undefined> {
const token = await this.db.tokenManager.getAccessToken();
if (!token) return;
const user = await this.db.user.getUser();
if (!token || !user) return;
if (isLegacySubscription(user)) {
return await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/update`,
token
);
} else {
const result = await http.get(
`${hosts.SUBSCRIPTIONS_HOST}/subscriptions/v2/urls`,
token
);
return result.update_payment_method;
}
}
async redeemCode(code: string) {
@@ -174,3 +239,14 @@ export default class Subscriptions {
);
}
}
function isLegacySubscription(user: User) {
const type = user.subscription.type;
return (
type !== undefined &&
(type === SubscriptionType.BETA ||
type === SubscriptionType.PREMIUM ||
type === SubscriptionType.PREMIUM_CANCELED ||
type === SubscriptionType.TRIAL)
);
}

View File

@@ -7060,8 +7060,8 @@ msgid "Welcome back!"
msgstr "Welcome back!"
#: src/strings.ts:2574
msgid "Welcome to Notesnook {plan} plan"
msgstr "Welcome to Notesnook {plan} plan"
msgid "Welcome to Notesnook {plan}"
msgstr "Welcome to Notesnook {plan}"
#: src/strings.ts:2072
msgid "Welcome to Notesnook Pro"

View File

@@ -7011,7 +7011,7 @@ msgid "Welcome back!"
msgstr ""
#: src/strings.ts:2574
msgid "Welcome to Notesnook {plan} plan"
msgid "Welcome to Notesnook {plan}"
msgstr ""
#: src/strings.ts:2072